fix bug of pargar

This commit is contained in:
2026-07-26 14:15:26 +03:30
parent 667e3736fb
commit 06b3113b98
17 changed files with 561 additions and 26 deletions
@@ -0,0 +1,111 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Services\PargarSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Rooyesh\Wallet\models\Transaction;
/**
* ساخت دوبارهٔ سند ریالی برای واریز/برداشت‌هایی که سندشان به پرگار نرسیده است.
*
* تا وقتی سند نرسد، ماندهٔ پرگار این پول را ندارد و تطبیق مانده موجودی کیف را صفر می‌کند.
*/
class PargarRequeueWalletDocumentsCommand extends Command
{
protected $signature = 'pargar:requeue-wallet-documents
{--customer= : فقط همین مشتری}
{--days=30 : چند روز اخیر بررسی شود}
{--execute : ثبت واقعی سندها (بدون این فلگ فقط پیش‌نمایش است)}';
protected $description = 'صف‌کردن دوبارهٔ اسناد ریالی جاافتادهٔ پرگار (واریز/برداشت بدون سند)';
public function handle(PargarSyncService $sync): int
{
if (! $sync->isSyncEnabled()) {
$this->error('همگام‌سازی پرگار فعال نیست.');
return self::FAILURE;
}
$days = max(1, (int) $this->option('days'));
$execute = (bool) $this->option('execute');
$customerId = (int) $this->option('customer');
$query = Transaction::query()
->whereIn('type', [3, 4])
->whereNotNull('withdraw_id')
->where('created_at', '>=', now()->subDays($days))
->where(function ($q) {
$q->where('cash', '>=', 1)->orWhere('cash', '<=', -1);
})
->whereNotExists(function ($q) {
$q->select(DB::raw(1))
->from('pargar_sync_logs')
->whereColumn('pargar_sync_logs.withdraw_id', 'transactions.withdraw_id')
->where('pargar_sync_logs.type', PargarSyncService::TYPE_WALLET_CASH);
})
->orderBy('id');
if ($customerId > 0) {
$query->where('customer_id', $customerId);
}
$rows = $query->get(['id', 'customer_id', 'withdraw_id', 'cash', 'note', 'created_at']);
if ($rows->isEmpty()) {
$this->info('سند جاافتاده‌ای پیدا نشد.');
return self::SUCCESS;
}
$customers = Customer::whereIn('id', $rows->pluck('customer_id')->unique())->get()->keyBy('id');
$table = [];
$queued = 0;
$skipped = 0;
foreach ($rows as $row) {
$customer = $customers->get((int) $row->customer_id);
$status = 'آماده';
if ($customer === null || ! $customer->hasValidPargarAccountId()) {
$status = 'بدون کد پرگار — رد شد';
$skipped++;
} elseif ($execute) {
$log = $sync->queueWalletDocument($customer, 'cash', (float) $row->cash, [
'withdraw_id' => (int) $row->withdraw_id,
'note' => $row->note,
'source' => 'requeue_missing',
]);
$status = $log !== null ? "ثبت شد (لاگ {$log->id})" : 'ثبت نشد';
$log !== null ? $queued++ : $skipped++;
}
$table[] = [
$row->id,
$row->customer_id,
$customer->name ?? '—',
$row->withdraw_id,
number_format((float) $row->cash),
(string) $row->created_at,
$status,
];
}
$this->table(
['تراکنش', 'مشتری', 'نام', 'سند برداشت', 'مبلغ', 'تاریخ', 'وضعیت'],
$table
);
if (! $execute) {
$this->warn('پیش‌نمایش بود. برای ثبت واقعی --execute بزنید.');
return self::SUCCESS;
}
$this->info("ثبت‌شده: {$queued} — ردشده: {$skipped}");
return self::SUCCESS;
}
}
@@ -142,6 +142,21 @@ class GiftCardController extends Controller
'withdraw_id' => $Withdraw->id,
'active' => true
], 3, false, true);
try {
app(\App\Services\PargarSyncService::class)->queueWalletDocument(
$user,
'cash',
(float) $giftcode->amount,
[
'withdraw_id' => $Withdraw->id,
'note' => 'کارت هدیه',
'source' => 'gift_card',
]
);
} catch (\Throwable $e) {
report($e);
}
}
//set text for sms to customer
@@ -666,6 +666,13 @@ class ProductController extends SuperController
unset($data['inventory']);
}
if (!Product::hasDescriptionColumn()) {
unset($data['description']);
} elseif (array_key_exists('description', $data)) {
$desc = is_string($data['description']) ? trim($data['description']) : '';
$data['description'] = $desc === '' ? null : $desc;
}
if (!Product::hasOverrideTradingHoursColumn()) {
unset($data['override_trading_hours']);
} elseif (array_key_exists('override_trading_hours', $data)) {
@@ -33,6 +33,40 @@ use Hekmatinasser\Verta\Verta;
class WalletController extends Controller
{
/** موجودی طلا با سه رقم اعشار نمایش داده می‌شود؛ نصف پلهٔ نمایش = بیشترین اختلاف ممکن با مقدار واقعی */
private const GOLD_DISPLAY_STEP = 0.0005;
/** پنجرهٔ تشخیص سند تکراری — دوبار کلیک یا ارسال مجدد فرم */
private const DUPLICATE_CHARGE_WINDOW_SECONDS = 60;
/**
* جلوگیری از ثبت دوبارهٔ همان سند دستی. فقط روی کسر اعمال می‌شود تا شارژهای
* مثبتِ پشت‌سرهم (مثلاً دو واریز یکسان) بسته نشوند.
*/
private function assertNotDuplicateCharge(int $customerId, float $value, string $column): void
{
if ($value >= 0) {
return;
}
$recent = Withdraw::query()
->where('customer_id', $customerId)
->where('sts', 4)
->where('created_at', '>=', now()->subSeconds(self::DUPLICATE_CHARGE_WINDOW_SECONDS));
if ($column === 'cash') {
$recent->where('cash', (int) $value);
} else {
$recent->whereBetween('gold', [$value - 1e-6, $value + 1e-6]);
}
if ($recent->exists()) {
throw new UnprocessableEntityHttpException(
'همین سند لحظاتی پیش برای این مشتری ثبت شده است. اگر تکرارش عمدی است یک دقیقه صبر کنید.'
);
}
}
public function charge2(Request $request)
{
$data = $request->all();
@@ -121,6 +155,7 @@ class WalletController extends Controller
if (!is_null($wallet) && $wallet->cash < abs($data['value']) && !$deposit) {
throw new UnprocessableEntityHttpException('مقدار موجودی کمتر از درخواست شماست');
}
$this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'cash');
$orders = Order::where('customer_id', $data['customer_id'])->where('sts', 1)->get();
$gold_block = 0;
@@ -213,12 +248,20 @@ class WalletController extends Controller
}
$wallet_interface = new WalletInterface($data['customer_id'], (int) $customer->type);
$wallet = $wallet_interface->wallet;
// if (is_null($wallet) && !$deposit) {
// throw new UnprocessableEntityHttpException('مقدار موجودی کمتر از درخواست شماست');
// }
// if (!is_null($wallet) && $wallet->cash < abs($data['value']) && !$deposit) {
// throw new UnprocessableEntityHttpException('مقدار موجودی کمتر از درخواست شماست');
// }
// کسر دستی نباید موجودی طلا را منفی کند؛ شارژ مثبت بدون هیچ شرطی باز است
if (!$deposit) {
$available = is_null($wallet) ? 0.0 : (float) $wallet->gold;
$requested = abs((float) $data['value']);
// ادمین عدد را از نمایش سه‌رقمی می‌خواند؛ اختلاف تا نصف پلهٔ نمایش یعنی «کل موجودی»
if ($requested > $available && $requested - $available <= self::GOLD_DISPLAY_STEP) {
$requested = $available;
$data['value'] = -$available;
}
if ($requested <= 0 || $requested > $available) {
throw new UnprocessableEntityHttpException('مقدار موجودی طلا کمتر از درخواست شماست');
}
}
$this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'gold');
$gold_block = 0;
$user = auth()->user();
$customer = Customer::findOrFail($data["customer_id"]);
@@ -386,6 +429,7 @@ class WalletController extends Controller
if (!$deposit && $wallet !== null && (float) $wallet->gold < abs((float) $data['value'])) {
throw new UnprocessableEntityHttpException('مقدار موجودی کمتر از درخواست شماست');
}
$this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'gold');
$goldBlock = 0;
$user = auth()->user();
@@ -391,6 +391,22 @@ class WalletController extends Controller
'withdraw_id' => $Withdraw->id,
'active' => true
], 4, false, false);
try {
app(\App\Services\PargarSyncService::class)->queueWalletDocument(
$customer,
'cash',
-1 * (float) $data['value'],
[
'withdraw_id' => $Withdraw->id,
'note' => 'برداشت ریالی',
'source' => 'customer_withdraw',
]
);
} catch (\Throwable $e) {
report($e);
}
$userMobile = $customer->mobile; // User's mobile number
$userName = $customer->name; // User's name
$withdrawAmount = $data['value']; // amount
@@ -195,6 +195,21 @@ class FinanceController extends Controller
'active' => true
], 3, false, true);
try {
app(\App\Services\PargarSyncService::class)->queueWalletDocument(
$User,
'cash',
(float) ($decodedResponse['data']['amount'] * 10),
[
'withdraw_id' => (int) $_GET['clientrefid'],
'note' => 'واریز درگاه',
'source' => 'gateway',
]
);
} catch (\Throwable $e) {
report($e);
}
//----------sms
// User information
$userMobile = $Withdraw->customer->mobile; // User's mobile number
@@ -1589,6 +1589,22 @@ class SingleOrderController extends Controller
'withdraw_id' => $Withdraw->id,
'active' => true,
], 3, true, true, 0);
try {
app(\App\Services\PargarSyncService::class)->queueWalletDocument(
$customer,
'cash',
(float) $customer_bonus,
[
'withdraw_id' => $Withdraw->id,
'note' => 'پاداش اولین سفارش',
'source' => 'bonus_first_order',
]
);
} catch (\Throwable $e) {
report($e);
}
$message = 'کیف پول شما به اندازه ' . number_format($customer_bonus) . ' ریال در تاریخ ' . verta()->format('Y/n/j H:i:s') . " شارژ شد بابت\n"
. 'پاداش اولین سفارش'
. ' '
@@ -1626,6 +1642,22 @@ class SingleOrderController extends Controller
'withdraw_id' => $Withdraw->id,
'active' => true,
], 3, true, true, 0);
try {
app(\App\Services\PargarSyncService::class)->queueWalletDocument(
$referrer,
'cash',
(float) $referrer_bonus,
[
'withdraw_id' => $Withdraw->id,
'note' => 'پاداش اولین سفارش زیرمجموعه',
'source' => 'bonus_referrer',
]
);
} catch (\Throwable $e) {
report($e);
}
$message = 'کیف پول شما به اندازه ' . number_format($referrer_bonus) . ' ریال در تاریخ ' . verta()->format('Y/n/j H:i:s') . " شارژ شد بابت\n"
. 'بابت پاداش اولین سفارش زیرمجموعه'
. ' '
+22
View File
@@ -27,6 +27,7 @@ class Product extends Model
private static ?bool $overrideTradingHoursColumnExists = null;
private static ?bool $cashFeePercentModeColumnsExist = null;
private static ?bool $physicalDeliveryColumnsExist = null;
private static ?bool $descriptionColumnExists = null;
protected static function booted(): void
{
@@ -59,6 +60,9 @@ class Product extends Model
if (!static::hasInventoryColumn()) {
unset($product->attributes['inventory']);
}
if (!static::hasDescriptionColumn()) {
unset($product->attributes['description']);
}
if (!static::hasOverrideTradingHoursColumn()) {
unset($product->attributes['override_trading_hours']);
}
@@ -293,6 +297,24 @@ class Product extends Model
return static::$inventoryColumnExists;
}
public static function hasDescriptionColumn(): bool
{
if (static::$descriptionColumnExists !== null) {
return static::$descriptionColumnExists;
}
try {
static::$descriptionColumnExists = Schema::hasColumn(
(new static)->getTable(),
'description'
);
} catch (\Throwable) {
static::$descriptionColumnExists = false;
}
return static::$descriptionColumnExists;
}
public function orderLimitsEnabled(): bool
{
if (!static::hasOrderLimitColumns() || !($this->order_limit_enabled ?? false)) {
+84 -2
View File
@@ -6,9 +6,11 @@ use App\Models\Customer;
use App\Models\PargarSyncLog;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Rooyesh\Wallet\interfaces\TransactionInterface;
use Rooyesh\Wallet\interfaces\WalletInterface;
use Rooyesh\Wallet\models\Transaction;
/**
* همگام‌سازی کیف با پرگار.
@@ -21,7 +23,9 @@ use Rooyesh\Wallet\interfaces\WalletInterface;
* پیش از ثبت سند گرفته شده باشد، اعمال نمی‌شود و منتظر ماندهٔ بعدی می‌مانیم.
* 4) اعمال فقط با دو snapshot پیاپیِ هم‌مقدار — تا مانده‌ای که وسط ثبت اسناد گرفته شده
* موجودی مشتری را عقب نبرد.
* 5) دکمهٔ ادمین با force می‌تواند همهٔ گیت‌های بالا را رد کند (ابزار اصلاح دستی).
* 5) اگر واریز/برداشت ریالی‌ای اصلاً سند پرگار نداشته باشد، مانده اعمال نمی‌شود؛
* پرگار از آن پول خبر ندارد و اعمال مانده موجودی مشتری را پاک می‌کند.
* 6) دکمهٔ ادمین با force می‌تواند همهٔ گیت‌های بالا را رد کند (ابزار اصلاح دستی).
*/
class PargarInboundWalletService
{
@@ -29,6 +33,9 @@ class PargarInboundWalletService
public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled';
/** واریز و برداشت ریالی — دو نوعی که باید سند ریالی پرگار داشته باشند */
public const CASH_DOCUMENT_TX_TYPES = [3, 4];
/** سقف تعداد دسته برای علامت‌زدن در هر کلیک */
private const MAX_BATCHES_PER_SYNC = 50;
@@ -342,12 +349,16 @@ class PargarInboundWalletService
$input['cash'] = abs($rialDiff);
}
// مانده پرگار می‌تواند منفی باشد و کیف باید همان را نشان دهد؛ کف موجودی اینجا اعمال نمی‌شود
new TransactionInterface(
$walletInterface,
$input,
self::TX_TYPE_PARGAR,
$goldDiff > 0,
$rialDiff > 0
$rialDiff > 0,
0,
[],
true
);
$data['wallet_applied_from_mande'] = true;
@@ -404,6 +415,16 @@ class PargarInboundWalletService
];
}
$missing = $this->unexportedCashMovement($customer, $lastConfirmedAt);
if ($missing !== null) {
return [
'reason' => 'unexported_wallet_movement',
'message' => 'حرکت ریالی '
. number_format($missing['amount'])
. ' ریال هنوز سندی در پرگار ندارد — تا ثبت سند، مانده اعمال نمی‌شود',
];
}
$previous = $this->previousComparableMande($log, $lastConfirmedAt);
if ($previous === null) {
return [
@@ -429,6 +450,67 @@ class PargarInboundWalletService
return null;
}
/**
* واریز/برداشت ریالی که سند پرگارش ساخته نشده است.
*
* تا وقتی پرگار از این حرکت خبر ندارد، ماندهٔ آن هم شامل این پول نیست؛ اعمال آن
* موجودی مشتری را پاک می‌کند. پس مانده را نگه می‌داریم تا سند ساخته و ثبت شود.
*
* @return array{amount: float, withdraw_ids: list<int>}|null
*/
private function unexportedCashMovement(Customer $customer, ?Carbon $lastConfirmedAt): ?array
{
$since = $this->cashMovementScanStart($customer, $lastConfirmedAt);
if ($since === null) {
return null;
}
$rows = Transaction::query()
->where('customer_id', $customer->id)
->whereIn('type', self::CASH_DOCUMENT_TX_TYPES)
->whereNotNull('withdraw_id')
->where('created_at', '>', $since)
->where(function ($q) {
$q->where('cash', '>=', 1)->orWhere('cash', '<=', -1);
})
->whereNotExists(function ($q) {
$q->select(DB::raw(1))
->from('pargar_sync_logs')
->whereColumn('pargar_sync_logs.withdraw_id', 'transactions.withdraw_id')
->where('pargar_sync_logs.type', PargarSyncService::TYPE_WALLET_CASH);
})
->get(['withdraw_id', 'cash']);
if ($rows->isEmpty()) {
return null;
}
return [
'amount' => (float) $rows->sum(fn ($row) => abs((float) $row->cash)),
'withdraw_ids' => $rows->pluck('withdraw_id')->map(fn ($id) => (int) $id)->all(),
];
}
/**
* از کجا دنبال سند جاافتاده بگردیم: بعد از آخرین سند ثبت‌شده، و اگر نبود از
* شروع اتصال همین مشتری به پرگار. تراکنش‌های قبل از اتصال سند ندارند و نباید
* سینک را برای همیشه قفل کنند.
*/
private function cashMovementScanStart(Customer $customer, ?Carbon $lastConfirmedAt): ?Carbon
{
$firstLogAt = PargarSyncLog::query()
->where('customer_id', $customer->id)
->min('created_at');
$integrationStart = $firstLogAt !== null ? Carbon::parse($firstLogAt) : null;
if ($lastConfirmedAt !== null && $integrationStart !== null) {
return $lastConfirmedAt->greaterThan($integrationStart) ? $lastConfirmedAt : $integrationStart;
}
return $lastConfirmedAt ?? $integrationStart;
}
/**
* ماندهٔ صفرِ هر دو فیلد برای مشتری‌ای که کیفش خالی نیست، تقریباً همیشه یعنی
* پرگار حساب را پیدا نکرده — نه اینکه مشتری تسویه کرده باشد.
+16 -2
View File
@@ -6,6 +6,7 @@ use App\Models\Customer;
use App\Models\Order;
use App\Models\PargarSyncLog;
use App\Models\Product;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
@@ -199,7 +200,7 @@ class PargarSyncService
$desc = $isDeposit ? 'شارژ طلای کیف اصلی — ارسال به پرگار' : 'برداشت طلای کیف اصلی — ارسال به پرگار';
}
return PargarSyncLog::create([
$attributes = [
'customer_id' => $customer->id,
'amount' => abs($signedAmount),
'desc' => $desc,
@@ -215,7 +216,20 @@ class PargarSyncService
'product_id' => $meta['product_id'] ?? null,
'source' => $meta['source'] ?? null,
], JSON_UNESCAPED_UNICODE),
]);
];
try {
return PargarSyncLog::create($attributes);
} catch (QueryException $e) {
report($e);
// مبلغ واقعی در data.signed_amount است و خروجی از همان می‌خواند؛
// پس اگر ستون amount مبلغ را نپذیرفت، سند را بدون آن ثبت می‌کنیم تا گم نشود.
$attributes['amount'] = null;
$attributes['sync_log'] = mb_substr('amount ثبت نشد: ' . $e->getMessage(), 0, 500);
return PargarSyncLog::create($attributes);
}
}
/**