From cecb84bad9a79df7c37f12c27f108dad40d92f97 Mon Sep 17 00:00:00 2001 From: adonis Date: Sat, 29 Aug 2026 14:08:51 +0330 Subject: [PATCH] Allow erase-bad-buy when the orders row was already deleted. Find orphan buy transactions or accept --gold/--transaction so wallet gold can still be stripped and cash recharged. Co-authored-by: Cursor --- .../EraseBadBuyAndRechargeCommand.php | 380 +++++++++++++----- 1 file changed, 280 insertions(+), 100 deletions(-) diff --git a/app/Console/Commands/EraseBadBuyAndRechargeCommand.php b/app/Console/Commands/EraseBadBuyAndRechargeCommand.php index 85fd865..a0e7129 100644 --- a/app/Console/Commands/EraseBadBuyAndRechargeCommand.php +++ b/app/Console/Commands/EraseBadBuyAndRechargeCommand.php @@ -11,6 +11,7 @@ use App\Models\Withdraw; use App\Models\Zarin; use App\Services\WalletBroadcastService; use Illuminate\Console\Command; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Rooyesh\Wallet\interfaces\TransactionInterface; @@ -20,110 +21,134 @@ use Rooyesh\Wallet\models\TransactionMeta; use Rooyesh\Wallet\models\Wallet; /** - * پاک‌کردن کامل یک خرید اشتباه (مثل ۱۰ میلیون → ۱۰۵ سوت) و شارژ مجدد ریالی. + * پاک‌کردن اثر خرید اشتباه و شارژ مجدد ریالی. * - * منطق: - * ۱) طلای همان سفارش از کیف کم می‌شود (بدون ثبت تراکنش برگشتی) - * ۲) سفارش + تراکنش‌ها + اسناد محلی مرتبط hard-delete می‌شوند - * ۳) مبلغ ریالی (--recharge-rial، پیش‌فرض ۱۰۰٬۰۰۰٬۰۰۰ = ۱۰ میلیون تومان) شارژ می‌شود - * - * توجه: سند همکار (Montzar) و سند راه دور زرین/ته‌حساب با این دستور لغو نمی‌شود. + * اگر ردیف orders دستی پاک شده باشد هم کار می‌کند: + * تراکنش‌های باقی‌مانده (یا --gold/--transaction) را پیدا می‌کند. */ class EraseBadBuyAndRechargeCommand extends Command { protected $signature = 'wallet:erase-bad-buy-recharge - {--order= : شناسه سفارش خرید اشتباه} - {--customer= : شناسه مشتری (اگر order ندادید؛ نزدیک‌ترین خرید ۱۰ میلیون / ~۰٫۱۰۵ گرم را پیدا می‌کند)} + {--order= : شناسه سفارش (اگر هنوز هست)} + {--customer= : شناسه مشتری — برای پیدا کردن سفارش یا تراکنش یتیم} + {--transaction= : شناسه تراکنش خرید (وقتی سفارش پاک شده)} + {--gold= : گرم طلایی که باید از کیف کم شود (مثلاً 0.105) اگر تراکنش هم نیست} {--recharge-rial=100000000 : مبلغ شارژ مجدد به ریال (پیش‌فرض ۱۰ میلیون تومان)} - {--user= : شناسه ادمین برای ردیف withdraw شارژ (پیش‌فرض اولین کاربر)} - {--skip-recharge : فقط پاک‌کردن خرید؛ شارژ نکن} - {--execute : اجرای واقعی (بدون این فلگ فقط پیش‌نمایش)}'; + {--user= : شناسه ادمین برای withdraw شارژ} + {--skip-recharge : فقط پاک‌کردن؛ شارژ نکن} + {--execute : اجرای واقعی}'; - protected $description = 'پاک‌کردن اثر خرید اشتباه و شارژ مجدد ریالی (بدون ردپای سفارش)'; + protected $description = 'پاک‌کردن اثر خرید اشتباه و شارژ مجدد ریالی (حتی اگر orders حذف شده باشد)'; public function handle(): int { $orderId = (int) $this->option('order'); $customerId = (int) $this->option('customer'); + $transactionId = (int) $this->option('transaction'); + $goldOpt = $this->option('gold'); + $goldForced = $goldOpt !== null && $goldOpt !== '' ? (float) $goldOpt : null; $rechargeRial = (int) round((float) $this->option('recharge-rial')); $skipRecharge = (bool) $this->option('skip-recharge'); $execute = (bool) $this->option('execute'); - $order = $this->resolveOrder($orderId, $customerId); - if (!$order) { + $resolved = $this->resolveTarget($orderId, $customerId, $transactionId, $goldForced); + if ($resolved === null) { return self::FAILURE; } - $customer = Customer::find($order->customer_id); - if (!$customer) { - $this->error("مشتری سفارش #{$order->id} یافت نشد."); - - return self::FAILURE; - } - - if ((int) $order->direction !== 1) { - $this->error("سفارش #{$order->id} خرید نیست (direction={$order->direction})."); - - return self::FAILURE; - } - - $txs = Transaction::query() - ->where('order_id', $order->id) - ->orderBy('id') - ->get(); - - if ($txs->isEmpty()) { - $this->warn("هیچ تراکنشی با order_id={$order->id} پیدا نشد — فقط سفارش حذف می‌شود."); - } + /** @var Customer $customer */ + $customer = $resolved['customer']; + /** @var Order|null $order */ + $order = $resolved['order']; + /** @var Collection $txs */ + $txs = $resolved['txs']; + $orphanOrderId = $resolved['orphan_order_id']; + $label = $resolved['label']; $goldByWallet = []; - $cashByWallet = []; foreach ($txs as $tx) { $wid = (int) $tx->wallet; $goldByWallet[$wid] = ($goldByWallet[$wid] ?? 0.0) + (float) $tx->gold; - $cashByWallet[$wid] = ($cashByWallet[$wid] ?? 0.0) + (float) $tx->cash; } - // فقط طلای واریزشده را برمی‌داریم؛ ریال کسرشده را با شارژ جدا برمی‌گردانیم تا «شارژ ۱۰ میلیون» در تاریخچه بماند. $goldToStrip = []; foreach ($goldByWallet as $wid => $delta) { if ($delta > 1e-12) { $goldToStrip[$wid] = $delta; } } - if ($goldToStrip === [] && (float) $order->amount > 0) { + + if ($goldToStrip === []) { $main = $this->mainWallet((int) $customer->id, (int) $customer->type); - if ($main) { - $goldToStrip[(int) $main->id] = (float) $order->amount; + $fallbackGold = $goldForced + ?? ($order ? (float) $order->amount : null) + ?? 0.105; + if ($main && $fallbackGold > 0) { + $goldToStrip[(int) $main->id] = $fallbackGold; + $this->warn("تراکنش طلای مثبت پیدا نشد — از مقدار {$fallbackGold} گرم روی کیف اصلی استفاده می‌شود."); } } - $adminRows = TransactionsAdmin::withTrashed()->where('order_id', $order->id)->get(); - $zarinRows = Schema::hasTable('zarin') - ? Zarin::query()->where('order_id', $order->id)->get() + if ($goldToStrip === []) { + $this->error('مقداری برای کسر طلا مشخص نشد. --gold=0.105 بدهید.'); + + return self::FAILURE; + } + + $refOrderId = $order?->id ?? $orphanOrderId; + $adminRows = $refOrderId + ? TransactionsAdmin::withTrashed()->where('order_id', $refOrderId)->get() : collect(); - $tahesabRows = Schema::hasTable('tahesab') - ? Tahesab::query()->where('order_id', $order->id)->get() + $zarinRows = ($refOrderId && Schema::hasTable('zarin')) + ? Zarin::query()->where('order_id', $refOrderId)->get() + : collect(); + $tahesabRows = ($refOrderId && Schema::hasTable('tahesab')) + ? Tahesab::query()->where('order_id', $refOrderId)->get() : collect(); - $payment = (float) ($order->payment_amount ?: $order->total_price); + // تراکنش‌های ادمین لینک‌شده به همین transaction idها + $txIds = $txs->pluck('id')->all(); + if ($txIds !== []) { + $adminByTx = TransactionsAdmin::withTrashed() + ->whereIn('transaction_id', $txIds) + ->get(); + $adminRows = $adminRows->merge($adminByTx)->unique('id'); + } + + $payment = (float) ($order?->payment_amount ?: $order?->total_price ?: 0); + if ($payment <= 0) { + $cashTaken = 0.0; + foreach ($txs as $tx) { + if ((float) $tx->cash < 0) { + $cashTaken += abs((float) $tx->cash); + } + } + $payment = $cashTaken; + } if ($rechargeRial <= 0) { $rechargeRial = (int) round($payment > 0 ? $payment : 100_000_000); } $this->info('=== پیش‌نمایش پاک‌کردن خرید اشتباه ==='); $this->line("مشتری: {$customer->id} — " . trim(($customer->fname ?? '') . ' ' . ($customer->lname ?? '') . ' ' . ($customer->name ?? ''))); - $this->line("سفارش: #{$order->id} | amount={$order->amount} گرم | price={$order->price} | payment={$payment} | provider_id=" . ($order->provider_id ?? '—')); - $this->line('تراکنش‌های سفارش: ' . $txs->pluck('id')->implode(', ')); - foreach ($txs as $tx) { - $this->line(" tx#{$tx->id} wallet={$tx->wallet} type={$tx->type} gold={$tx->gold} cash={$tx->cash} title=" . ($tx->title ?? '')); + $this->line('هدف: ' . $label); + if ($order) { + $this->line("سفارش موجود: #{$order->id} | amount={$order->amount} | price={$order->price} | payment={$payment}"); + } elseif ($orphanOrderId) { + $this->warn("سفارش #{$orphanOrderId} در جدول orders نیست (احتمالاً دستی پاک شده) — فقط اثر باقی‌مانده پاک می‌شود."); + } else { + $this->warn('بدون ردیف سفارش — فقط تراکنش/موجودی اصلاح می‌شود.'); } - $this->line('دفترکل محلی (transactions_admins): ' . $adminRows->count()); - $this->line('اسناد zarin محلی: ' . $zarinRows->count()); - $this->line('اسناد tahesab محلی: ' . $tahesabRows->count()); + + $this->line('تراکنش‌ها: ' . ($txIds ? implode(', ', $txIds) : '—')); + foreach ($txs as $tx) { + $this->line(" tx#{$tx->id} wallet={$tx->wallet} type={$tx->type} gold={$tx->gold} cash={$tx->cash} order_id=" . ($tx->order_id ?? 'null') . ' title=' . ($tx->title ?? '')); + } + $this->line('دفترکل محلی: ' . $adminRows->count()); + $this->line('zarin محلی: ' . $zarinRows->count() . ' | tahesab محلی: ' . $tahesabRows->count()); $this->newLine(); - $this->info('--- تغییر موجودی طلا (بدون ثبت تراکنش برگشتی) ---'); + $this->info('--- کسر طلا از کیف (بدون تراکنش برگشتی) ---'); foreach ($goldToStrip as $wid => $g) { $w = Wallet::find($wid); if (!$w) { @@ -135,7 +160,7 @@ class EraseBadBuyAndRechargeCommand extends Command $after = $before - $g; $this->line("wallet#{$wid} gold: {$before} → {$after} (−{$g})"); if ($after < -1e-6) { - $this->error('بعد از کسر، طلا منفی می‌شود — متوقف شد.'); + $this->error('بعد از کسر طلا منفی می‌شود — متوقف شد.'); return self::FAILURE; } @@ -148,16 +173,22 @@ class EraseBadBuyAndRechargeCommand extends Command $this->info('--- شارژ مجدد ریالی ---'); $this->line('مبلغ: ' . number_format($rechargeRial) . ' ریال (= ' . number_format($rechargeRial / 10) . ' تومان)'); $this->line('کیف اصلی cash: ' . number_format($cashBefore) . ' → ' . number_format($cashBefore + $rechargeRial)); - $this->warn('سند حسابداری راه دور برای این شارژ ساخته نمی‌شود (فقط کیف + withdraw + تراکنش).'); } $this->newLine(); - $this->warn('Montzar / زرین راه دور: این دستور سفارش همکار و سند آنلاین را لغو نمی‌کند.'); + $this->warn('Montzar / زرین راه دور لغو نمی‌شود.'); if (!$execute) { $this->newLine(); - $this->warn('حالت پیش‌نمایش — برای اجرا:'); - $this->line(' php artisan wallet:erase-bad-buy-recharge --order=' . $order->id . ' --execute'); + $this->warn('پیش‌نمایش — برای اجرا یکی از این‌ها:'); + if ($order) { + $this->line(" php artisan wallet:erase-bad-buy-recharge --order={$order->id} --execute"); + } elseif ($txIds !== []) { + $this->line(' php artisan wallet:erase-bad-buy-recharge --customer=' . $customer->id . ' --transaction=' . $txIds[0] . ' --execute'); + } else { + $g = reset($goldToStrip); + $this->line(' php artisan wallet:erase-bad-buy-recharge --customer=' . $customer->id . ' --gold=' . $g . ' --execute'); + } return self::SUCCESS; } @@ -167,11 +198,13 @@ class EraseBadBuyAndRechargeCommand extends Command $adminUserId = (int) (User::query()->orderBy('id')->value('id') ?: 0); } if (!$skipRecharge && $adminUserId <= 0) { - $this->error('کاربر ادمین برای withdraw پیدا نشد؛ --user بدهید.'); + $this->error('کاربر ادمین پیدا نشد؛ --user بدهید.'); return self::FAILURE; } + $noteTag = $order?->id ?? $orphanOrderId ?? ($txIds[0] ?? 'manual'); + DB::transaction(function () use ( $order, $customer, @@ -182,7 +215,9 @@ class EraseBadBuyAndRechargeCommand extends Command $tahesabRows, $skipRecharge, $rechargeRial, - $adminUserId + $adminUserId, + $noteTag, + $refOrderId ) { foreach ($goldToStrip as $wid => $g) { $w = Wallet::query()->lockForUpdate()->find($wid); @@ -196,12 +231,12 @@ class EraseBadBuyAndRechargeCommand extends Command $w->save(); } - $txIds = $txs->pluck('id')->all(); - if ($txIds !== []) { + $ids = $txs->pluck('id')->all(); + if ($ids !== []) { if (Schema::hasTable('transaction_metas')) { - TransactionMeta::query()->whereIn('transaction', $txIds)->delete(); + TransactionMeta::query()->whereIn('transaction', $ids)->delete(); } - Transaction::query()->whereIn('id', $txIds)->delete(); + Transaction::query()->whereIn('id', $ids)->delete(); } foreach ($adminRows as $row) { @@ -214,12 +249,13 @@ class EraseBadBuyAndRechargeCommand extends Command $row->delete(); } - // پاداش معرفی / دفترکل وابسته به همین سفارش - if (Schema::hasColumn('transactions_admins', 'order_id')) { - TransactionsAdmin::withTrashed()->where('order_id', $order->id)->forceDelete(); + if ($refOrderId && Schema::hasColumn('transactions_admins', 'order_id')) { + TransactionsAdmin::withTrashed()->where('order_id', $refOrderId)->forceDelete(); } - Order::query()->where('id', $order->id)->delete(); + if ($order) { + Order::query()->where('id', $order->id)->delete(); + } if (!$skipRecharge) { $walletInterface = new WalletInterface((int) $customer->id, (int) $customer->type); @@ -227,7 +263,7 @@ class EraseBadBuyAndRechargeCommand extends Command 'user_id' => $adminUserId, 'customer_id' => $customer->id, 'cash' => $rechargeRial, - 'note' => 'شارژ اصلاحی پس از حذف خرید اشتباه #' . $order->id, + 'note' => 'شارژ اصلاحی پس از حذف خرید اشتباه #' . $noteTag, 'sts' => 4, ]); @@ -267,37 +303,113 @@ class EraseBadBuyAndRechargeCommand extends Command } $this->newLine(); - $this->info('انجام شد: سفارش و تراکنش‌هایش پاک شدند' . ($skipRecharge ? '.' : ' و شارژ ریالی ثبت شد.')); + $this->info('انجام شد' . ($skipRecharge ? '.' : ' — شارژ ریالی هم ثبت شد.')); $main = $this->mainWallet((int) $customer->id, (int) $customer->type); if ($main) { - $this->line('موجودی فعلی کیف اصلی — cash=' . number_format((float) $main->cash) . ' | gold=' . round((float) $main->gold, 6)); + $this->line('موجودی کیف اصلی — cash=' . number_format((float) $main->cash) . ' | gold=' . round((float) $main->gold, 6)); } return self::SUCCESS; } - private function resolveOrder(int $orderId, int $customerId): ?Order + /** + * @return array{customer: Customer, order: ?Order, txs: Collection, orphan_order_id: ?int, label: string}|null + */ + private function resolveTarget(int $orderId, int $customerId, int $transactionId, ?float $goldForced): ?array { + // ۱) سفارش هنوز هست if ($orderId > 0) { $order = Order::find($orderId); if (!$order) { - $this->error("سفارش #{$orderId} یافت نشد."); + $this->warn("سفارش #{$orderId} در orders نیست — سراغ تراکنش‌های order_id={$orderId} می‌رویم."); + $txs = Transaction::query()->where('order_id', $orderId)->orderBy('id')->get(); + if ($txs->isEmpty() && $customerId <= 0) { + $this->error('تراکنشی با این order_id هم نیست. --customer و --gold یا --transaction بدهید.'); + + return null; + } + $customer = $customerId > 0 + ? Customer::find($customerId) + : Customer::find((int) ($txs->first()?->customer_id)); + if (!$customer) { + $this->error('مشتری مشخص نشد.'); + + return null; + } + + return [ + 'customer' => $customer, + 'order' => null, + 'txs' => $txs, + 'orphan_order_id' => $orderId, + 'label' => "سفارش حذف‌شده #{$orderId}", + ]; + } + + $customer = Customer::find($order->customer_id); + if (!$customer) { + $this->error('مشتری سفارش یافت نشد.'); return null; } - return $order; + return [ + 'customer' => $customer, + 'order' => $order, + 'txs' => Transaction::query()->where('order_id', $order->id)->orderBy('id')->get(), + 'orphan_order_id' => null, + 'label' => "سفارش #{$order->id}", + ]; + } + + // ۲) تراکنش مشخص + if ($transactionId > 0) { + $tx = Transaction::find($transactionId); + if (!$tx) { + $this->error("تراکنش #{$transactionId} یافت نشد."); + + return null; + } + $customer = Customer::find((int) $tx->customer_id); + if (!$customer) { + $this->error('مشتری تراکنش یافت نشد.'); + + return null; + } + $txs = collect([$tx]); + if ($tx->order_id) { + $siblings = Transaction::query() + ->where('order_id', $tx->order_id) + ->where('id', '!=', $tx->id) + ->get(); + $txs = $txs->merge($siblings)->sortBy('id')->values(); + } + + return [ + 'customer' => $customer, + 'order' => $tx->order_id ? Order::find($tx->order_id) : null, + 'txs' => $txs, + 'orphan_order_id' => $tx->order_id && ! Order::find($tx->order_id) ? (int) $tx->order_id : null, + 'label' => "تراکنش #{$transactionId}", + ]; } if ($customerId <= 0) { - $this->error('--order یا --customer الزامی است.'); + $this->error('--order یا --customer یا --transaction الزامی است.'); return null; } - // نزدیک به کیس ۱۰ میلیون تومان / ~۰٫۱۰۵ گرم - $candidates = Order::query() + $customer = Customer::find($customerId); + if (!$customer) { + $this->error("مشتری {$customerId} یافت نشد."); + + return null; + } + + // ۳) سفارش شبیه ۱۰ میلیون / ۰٫۱۰۵ + $order = Order::query() ->where('customer_id', $customerId) ->where('direction', 1) ->where('sts', 2) @@ -307,32 +419,100 @@ class EraseBadBuyAndRechargeCommand extends Command ->orWhereBetween('total_price', [90_000_000, 110_000_000]); }) ->orderByDesc('id') - ->get(); + ->first(); - if ($candidates->isEmpty()) { - $this->error("برای مشتری {$customerId} سفارشی شبیه ۱۰ میلیون / ~۰٫۱۰۵ گرم پیدا نشد. --order بدهید."); - $this->line('آخرین خریدها:'); - Order::query() - ->where('customer_id', $customerId) - ->where('direction', 1) - ->orderByDesc('id') - ->limit(8) - ->get(['id', 'amount', 'price', 'payment_amount', 'total_price', 'created_at']) - ->each(function (Order $o) { - $this->line(" #{$o->id} amount={$o->amount} price={$o->price} pay={$o->payment_amount} total={$o->total_price} at={$o->created_at}"); - }); - - return null; + if ($order) { + return [ + 'customer' => $customer, + 'order' => $order, + 'txs' => Transaction::query()->where('order_id', $order->id)->orderBy('id')->get(), + 'orphan_order_id' => null, + 'label' => "سفارش #{$order->id}", + ]; } - if ($candidates->count() > 1) { - $this->warn('چند نامزد پیدا شد؛ اولی (جدیدتر) انتخاب می‌شود. در صورت نیاز --order بدهید:'); - foreach ($candidates as $o) { - $this->line(" #{$o->id} amount={$o->amount} pay={$o->payment_amount} price={$o->price}"); + // ۴) تراکنش یتیم: طلا ~۰٫۱۰۵ و/یا cash ~−۱۰۰ میلیون + $orphanTxs = Transaction::query() + ->where('customer_id', $customerId) + ->where(function ($q) { + $q->whereBetween('gold', [0.09, 0.12]) + ->orWhereBetween('cash', [-110_000_000, -90_000_000]); + }) + ->orderByDesc('id') + ->limit(20) + ->get(); + + // گروه‌بندی با order_id (حتی اگر سفارش پاک شده) + $byOrder = $orphanTxs->groupBy(fn ($t) => $t->order_id ?: ('tx:' . $t->id)); + $best = null; + $bestScore = -1; + foreach ($byOrder as $group) { + $g = (float) $group->sum('gold'); + $c = (float) $group->sum(fn ($t) => min(0, (float) $t->cash)); + $score = 0; + if ($g >= 0.09 && $g <= 0.12) { + $score += 2; + } + if ($c <= -90_000_000 && $c >= -110_000_000) { + $score += 2; + } + if ($score > $bestScore) { + $bestScore = $score; + $best = $group; } } - return $candidates->first(); + if ($best && $bestScore > 0) { + $oid = $best->first()->order_id; + + return [ + 'customer' => $customer, + 'order' => $oid ? Order::find($oid) : null, + 'txs' => $best->sortBy('id')->values(), + 'orphan_order_id' => $oid && ! Order::find($oid) ? (int) $oid : null, + 'label' => $oid ? "تراکنش‌های order_id={$oid} (سفارش حذف‌شده؟)" : 'تراکنش یتیم طلا/ریال', + ]; + } + + // ۵) فقط --gold: موجودی را کم کن، تراکنشی پاک نکن مگر پیدا شود + if ($goldForced !== null && $goldForced > 0) { + $this->warn("سفارش/تراکنش مشابه پیدا نشد — فقط --gold={$goldForced} روی کیف اعمال می‌شود."); + + return [ + 'customer' => $customer, + 'order' => null, + 'txs' => collect(), + 'orphan_order_id' => null, + 'label' => "کسر دستی طلا {$goldForced} گرم", + ]; + } + + $this->error("برای مشتری {$customerId} نه سفارش مانده نه تراکنش شبیه ۱۰ میلیون / ۰٫۱۰۵ گرم."); + $this->line('آخرین خریدهای orders:'); + Order::query() + ->where('customer_id', $customerId) + ->where('direction', 1) + ->orderByDesc('id') + ->limit(8) + ->get(['id', 'amount', 'price', 'payment_amount', 'total_price', 'created_at']) + ->each(function (Order $o) { + $this->line(" #{$o->id} amount={$o->amount} price={$o->price} pay={$o->payment_amount} at={$o->created_at}"); + }); + $this->line('آخرین تراکنش‌های طلای مثبت:'); + Transaction::query() + ->where('customer_id', $customerId) + ->where('gold', '>', 0) + ->orderByDesc('id') + ->limit(10) + ->get(['id', 'order_id', 'type', 'gold', 'cash', 'title', 'created_at']) + ->each(function (Transaction $t) { + $this->line(" tx#{$t->id} order_id={$t->order_id} type={$t->type} gold={$t->gold} cash={$t->cash} at={$t->created_at}"); + }); + $this->newLine(); + $this->warn('اگر فقط طلا مانده و تراکنش را هم پاک کرده‌اید:'); + $this->line(" php artisan wallet:erase-bad-buy-recharge --customer={$customerId} --gold=0.105 --execute"); + + return null; } private function mainWallet(int $customerId, int $ownerType): ?Wallet