fix transaction bug
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Console\Command;
|
||||
use Rooyesh\Wallet\models\Transaction;
|
||||
use Rooyesh\Wallet\models\Wallet;
|
||||
|
||||
/**
|
||||
* پیدا کردن مشتریانی که با باگ intval روی کیف محصول وزنی، طلا از موجودی پریده
|
||||
* ولی تراکنشها سر جایشان ماندهاند (الگوی فاطمه / مشتری ۵۳۵۵).
|
||||
*
|
||||
* فقط گزارش میدهد — موجودی را عوض نمیکند.
|
||||
*/
|
||||
class AuditIntvalGoldLossCommand extends Command
|
||||
{
|
||||
protected $signature = 'wallet:audit-intval-gold-loss
|
||||
{--customer= : فقط یک مشتری}
|
||||
{--min=0.001 : حداقل اختلاف گرم برای گزارش}
|
||||
{--limit=200 : حداکثر ردیف خروجی}';
|
||||
|
||||
protected $description = 'گزارش کیفهای وزنی که موجودیشان با جمع تراکنشها نمیخواند (احتمال باگ intval)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$min = max(0.000001, (float) $this->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<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')
|
||||
->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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user