diff --git a/app/Console/Commands/PargarRequeueWalletDocumentsCommand.php b/app/Console/Commands/PargarRequeueWalletDocumentsCommand.php index dd1e901..afe68b7 100644 --- a/app/Console/Commands/PargarRequeueWalletDocumentsCommand.php +++ b/app/Console/Commands/PargarRequeueWalletDocumentsCommand.php @@ -73,13 +73,9 @@ class PargarRequeueWalletDocumentsCommand extends Command $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++; + $queuedCount = $sync->queueMissingCashDocuments($customer, [$row], 'requeue_missing'); + $status = $queuedCount > 0 ? 'ثبت/موجود شد' : 'ثبت نشد'; + $queuedCount > 0 ? $queued++ : $skipped++; } $table[] = [ diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index bd3037a..594be4f 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -29,6 +29,12 @@ class Kernel extends ConsoleKernel $schedule->command('prices:prune') ->dailyAt('00:00') ->name('prune_price_snapshots'); + + // ترمیم اسناد ریالی جاافتادهٔ پرگار (واریز/برداشت بدون لاگ) تا مانده قفل نماند + $schedule->command('pargar:requeue-wallet-documents --execute --days=120') + ->hourly() + ->name('pargar_requeue_wallet_docs') + ->withoutOverlapping(50); } protected function commands() diff --git a/app/Services/MontzarService.php b/app/Services/MontzarService.php index 591dd5d..1afea23 100644 --- a/app/Services/MontzarService.php +++ b/app/Services/MontzarService.php @@ -33,17 +33,29 @@ class MontzarService public function montzarLogin(): string { - $response = Http::timeout(10) - ->acceptJson() - ->post($this->endpoint('/server/auth/login'), [ - 'mobile' => $this->config()->Config('montzar_mobile'), - 'password' => $this->config()->Config('montzar_password'), - 'provider' => 'customer', - 'type_app' => 'client', - ]) - ->json(); + try { + $response = Http::timeout(10) + ->acceptJson() + ->post($this->endpoint('/server/auth/login'), [ + 'mobile' => $this->config()->Config('montzar_mobile'), + 'password' => $this->config()->Config('montzar_password'), + 'provider' => 'customer', + 'type_app' => 'client', + ]) + ->json(); + } catch (\Throwable $e) { + Log::error('Montzar login request failed', [ + 'base_url' => $this->baseUrl(), + 'error' => $e->getMessage(), + ]); + throw new UnprocessableEntityHttpException( + 'سرویس موقتا در دسترس نیست، لطفاً با پشتیبانی تماس بگیرید.' + ); + } - $token = $response['access_token'] ?? $response['token'] ?? null; + $token = is_array($response) + ? ($response['access_token'] ?? $response['token'] ?? null) + : null; if (!$token) { Log::error('Montzar login failed', [ @@ -51,7 +63,10 @@ class MontzarService 'mobile' => $this->config()->Config('montzar_mobile'), 'response' => $response, ]); - throw new \Exception('Montzar login failed: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); + // 422 تا فرانت toast نشان دهد؛ جزئیات فنی فقط در لاگ می‌ماند + throw new UnprocessableEntityHttpException( + 'سرویس موقتا در دسترس نیست، لطفاً با پشتیبانی تماس بگیرید.' + ); } Setting::updateOrCreate( diff --git a/app/Services/PargarInboundWalletService.php b/app/Services/PargarInboundWalletService.php index 8a3185b..ed3e247 100644 --- a/app/Services/PargarInboundWalletService.php +++ b/app/Services/PargarInboundWalletService.php @@ -25,7 +25,8 @@ use Rooyesh\Wallet\models\Transaction; * 4) ماندهٔ تازهٔ پرگار (از جمله صفر/تغییر دستی حسابدار) بلافاصله اعمال می‌شود — * دیگر منتظر دو snapshot یکسان یا بلوک صفر نمی‌مانیم. * 5) اگر واریز/برداشت ریالی‌ای اصلاً سند پرگار نداشته باشد، مانده اعمال نمی‌شود؛ - * پرگار از آن پول خبر ندارد و اعمال مانده موجودی مشتری را پاک می‌کند. + * همزمان سند جاافتاده خودکار صف می‌شود. پرگار از آن پول خبر ندارد و اعمال مانده + * موجودی مشتری را پاک می‌کند. * 6) دکمهٔ ادمین با force می‌تواند گیت‌های سند pending را رد کند (ابزار اصلاح دستی). */ class PargarInboundWalletService @@ -502,11 +503,28 @@ class PargarInboundWalletService $missing = $this->unexportedCashMovement($customer, $lastConfirmedAt); if ($missing !== null) { + // ترمیم خودکار: سند جاافتاده را صف کن تا pending بماند و بعد از ثبت در پرگار مانده باز شود + $queued = 0; + try { + $queued = app(PargarSyncService::class)->queueMissingCashDocuments( + $customer, + $missing['rows'], + 'mande_auto_missing' + ); + } catch (\Throwable $e) { + report($e); + } + + $suffix = $queued > 0 + ? ' — سند در صف ثبت قرار گرفت؛ تا تأیید پرگار، مانده اعمال نمی‌شود' + : ' — تا ثبت سند، مانده اعمال نمی‌شود'; + return [ 'reason' => 'unexported_wallet_movement', 'message' => 'حرکت ریالی ' . number_format($missing['amount']) - . ' ریال هنوز سندی در پرگار ندارد — تا ثبت سند، مانده اعمال نمی‌شود', + . ' ریال هنوز سندی در پرگار ندارد' + . $suffix, ]; } @@ -529,7 +547,7 @@ class PargarInboundWalletService * تا وقتی پرگار از این حرکت خبر ندارد، ماندهٔ آن هم شامل این پول نیست؛ اعمال آن * موجودی مشتری را پاک می‌کند. پس مانده را نگه می‌داریم تا سند ساخته و ثبت شود. * - * @return array{amount: float, withdraw_ids: list}|null + * @return array{amount: float, withdraw_ids: list, rows: \Illuminate\Support\Collection}|null */ private function unexportedCashMovement(Customer $customer, ?Carbon $lastConfirmedAt): ?array { @@ -552,7 +570,7 @@ class PargarInboundWalletService ->whereColumn('pargar_sync_logs.withdraw_id', 'transactions.withdraw_id') ->where('pargar_sync_logs.type', PargarSyncService::TYPE_WALLET_CASH); }) - ->get(['withdraw_id', 'cash']); + ->get(['withdraw_id', 'cash', 'note']); if ($rows->isEmpty()) { return null; @@ -561,6 +579,7 @@ class PargarInboundWalletService return [ 'amount' => (float) $rows->sum(fn ($row) => abs((float) $row->cash)), 'withdraw_ids' => $rows->pluck('withdraw_id')->map(fn ($id) => (int) $id)->all(), + 'rows' => $rows, ]; } diff --git a/app/Services/PargarSyncService.php b/app/Services/PargarSyncService.php index 22b9f31..dbea45d 100644 --- a/app/Services/PargarSyncService.php +++ b/app/Services/PargarSyncService.php @@ -151,9 +151,18 @@ class PargarSyncService ]; } + /** + * سقف تاریخی ستون amount وقتی هنوز DOUBLE(8,2) است (~۹۹۹٬۹۹۹٫۹۹). + * مبلغ واقعی همیشه در data.signed_amount نگه داشته می‌شود. + */ + private const AMOUNT_COLUMN_SAFE_MAX = 999999.99; + /** * صف سند کیف برای سینک مانده پرگار با اپ. * + * سند pending باید حتماً ساخته شود؛ اگر ستون amount مبلغ بزرگ را نپذیرد، + * بدون amount ثبت می‌شود تا حرکت کیف بی‌سندی نماند و مانده قفل نشود. + * * @param 'cash'|'gold' $kind * @param array{withdraw_id?:int|null,note?:string|null,product_id?:int|null,source?:string|null} $meta */ @@ -201,9 +210,14 @@ class PargarSyncService $desc = $isDeposit ? 'شارژ طلای کیف اصلی — ارسال به پرگار' : 'برداشت طلای کیف اصلی — ارسال به پرگار'; } + $absAmount = abs($signedAmount); + // اگر هنوز مایگریشن widen اجرا نشده، مبلغ بزرگ را از همان اول در ستون نگذار + // تا insert نترکد؛ خروجی/مانده از signed_amount می‌خوانند. + $columnAmount = $absAmount <= self::AMOUNT_COLUMN_SAFE_MAX ? $absAmount : null; + $attributes = [ 'customer_id' => $customer->id, - 'amount' => abs($signedAmount), + 'amount' => $columnAmount, 'desc' => $desc, 'withdraw_id' => $withdrawId, 'date' => now(), @@ -219,20 +233,145 @@ class PargarSyncService ], JSON_UNESCAPED_UNICODE), ]; + if ($columnAmount === null) { + $attributes['sync_log'] = 'amount بزرگ‌تر از سقف ستون بود؛ مبلغ در data.signed_amount است'; + } + + return $this->createWalletSyncLogSafely($attributes, $absAmount); + } + + /** + * ساخت لاگ سند کیف بدون امکان گم‌شدن به‌خاطر سقف ستون amount. + * + * @param array $attributes + */ + private function createWalletSyncLogSafely(array $attributes, float $absAmount): PargarSyncLog + { try { + // اگر ستون widen شده، مبلغ کامل را هم در amount بنویس (نمایش ادمین) + if (($attributes['amount'] ?? null) === null && $absAmount > self::AMOUNT_COLUMN_SAFE_MAX) { + $withAmount = $attributes; + $withAmount['amount'] = $absAmount; + unset($withAmount['sync_log']); + + try { + return PargarSyncLog::create($withAmount); + } catch (QueryException $e) { + report($e); + $attributes['sync_log'] = mb_substr( + 'amount ثبت نشد: ' . $e->getMessage(), + 0, + 500 + ); + } + } + 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); + try { + return PargarSyncLog::create($attributes); + } catch (\Throwable $e2) { + report($e2); + + return $this->insertWalletSyncLogRaw($attributes, $e2); + } } } + /** + * آخرین پناه: insert خام تا سند pending حتماً در DB بماند. + * + * @param array $attributes + */ + private function insertWalletSyncLogRaw(array $attributes, \Throwable $previous): PargarSyncLog + { + $now = now(); + $row = [ + 'customer_id' => $attributes['customer_id'] ?? null, + 'amount' => null, + 'desc' => $attributes['desc'] ?? null, + 'order_id' => $attributes['order_id'] ?? null, + 'withdraw_id' => $attributes['withdraw_id'] ?? null, + 'date' => $attributes['date'] ?? $now, + 'type' => $attributes['type'] ?? null, + 'sync_status' => $attributes['sync_status'] ?? self::STATUS_PENDING, + 'sync_log' => mb_substr( + (string) ($attributes['sync_log'] ?? '') . ' | raw insert: ' . $previous->getMessage(), + 0, + 500 + ), + 'data' => $attributes['data'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + try { + $id = DB::table('pargar_sync_logs')->insertGetId($row); + $log = PargarSyncLog::find($id); + if ($log !== null) { + Log::warning('Pargar wallet document saved via raw insert', [ + 'pargar_sync_log_id' => $id, + 'withdraw_id' => $row['withdraw_id'], + 'customer_id' => $row['customer_id'], + ]); + + return $log; + } + } catch (\Throwable $e) { + report($e); + Log::critical('Pargar wallet document queue failed completely', [ + 'customer_id' => $row['customer_id'], + 'withdraw_id' => $row['withdraw_id'], + 'type' => $row['type'], + 'error' => $e->getMessage(), + ]); + + throw $e; + } + + throw new \RuntimeException( + 'ساخت سند کیف پرگار ناموفق بود (withdraw_id=' . ($row['withdraw_id'] ?? 'null') . ')' + ); + } + + /** + * صف‌کردن سند برای واریز/برداشت‌هایی که لاگ پرگار ندارند. + * + * @param iterable $rows + * @return int تعداد سند تازه‌ساخته یا موجود + */ + public function queueMissingCashDocuments(Customer $customer, iterable $rows, string $source = 'auto_missing'): int + { + if (! $this->isSyncEnabled() || ! $this->hasSyncTable()) { + return 0; + } + + $queued = 0; + foreach ($rows as $row) { + $withdrawId = (int) ($row->withdraw_id ?? 0); + $cash = (float) ($row->cash ?? 0); + if ($withdrawId <= 0 || abs($cash) < 1) { + continue; + } + + $log = $this->queueWalletDocument($customer, 'cash', $cash, [ + 'withdraw_id' => $withdrawId, + 'note' => $row->note ?? null, + 'source' => $source, + ]); + if ($log !== null) { + $queued++; + } + } + + return $queued; + } + /** * @param array{ * payment_kind?: string, diff --git a/database/migrations/2026_07_05_000002_create_pargar_integration.php b/database/migrations/2026_07_05_000002_create_pargar_integration.php index ddfb0a4..b65ded1 100644 --- a/database/migrations/2026_07_05_000002_create_pargar_integration.php +++ b/database/migrations/2026_07_05_000002_create_pargar_integration.php @@ -24,7 +24,8 @@ return new class extends Migration Schema::create('pargar_sync_logs', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('customer_id')->nullable()->index(); - $table->float('amount')->nullable(); + // decimal نه float — float() لاراول ۸ می‌شود DOUBLE(8,2) و مبالغ بزرگ گم می‌شوند + $table->decimal('amount', 20, 2)->nullable(); $table->string('desc')->nullable(); $table->unsignedBigInteger('order_id')->nullable()->index(); $table->unsignedBigInteger('withdraw_id')->nullable()->index(); diff --git a/database/migrations/2026_07_26_000002_widen_pargar_sync_logs_amount.php b/database/migrations/2026_07_26_000002_widen_pargar_sync_logs_amount.php index 67e9369..ac0a70a 100644 --- a/database/migrations/2026_07_26_000002_widen_pargar_sync_logs_amount.php +++ b/database/migrations/2026_07_26_000002_widen_pargar_sync_logs_amount.php @@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Schema; * ستون amount با float() ساخته شده بود و لاراول ۸ آن را DOUBLE(8,2) می‌سازد؛ * سقفش ۹۹۹٬۹۹۹٫۹۹ است. هر سند ریالی بزرگ‌تر از یک میلیون در insert خطا می‌داد و * چون فراخوانی‌ها داخل try/catch بودند، سند بی‌صدا گم می‌شد و پرگار واریز را نمی‌دید. + * + * DECIMAL(20,2) برای مبالغ ریالی بزرگ (میلیاردها) کافی و امن است. + * idempotent: اگر از قبل DECIMAL/DOUBLE بدون سقف باشد هم آسیب نمی‌زند. */ return new class extends Migration { @@ -17,7 +20,7 @@ return new class extends Migration return; } - DB::statement('ALTER TABLE `pargar_sync_logs` MODIFY `amount` DOUBLE NULL'); + DB::statement('ALTER TABLE `pargar_sync_logs` MODIFY `amount` DECIMAL(20, 2) NULL'); } public function down(): void diff --git a/database/migrations/2026_08_01_000001_pargar_sync_logs_amount_decimal.php b/database/migrations/2026_08_01_000001_pargar_sync_logs_amount_decimal.php new file mode 100644 index 0000000..edc3bb5 --- /dev/null +++ b/database/migrations/2026_08_01_000001_pargar_sync_logs_amount_decimal.php @@ -0,0 +1,39 @@ +column_type ?? '')); + if ($columnType === 'decimal(20,2)') { + return; + } + + DB::statement('ALTER TABLE `pargar_sync_logs` MODIFY `amount` DECIMAL(20, 2) NULL'); + } + + public function down(): void + { + // برگشت عمداً انجام نمی‌شود. + } +}; diff --git a/database/sql/pargar_sync_logs_amount_widen.sql b/database/sql/pargar_sync_logs_amount_widen.sql index 7ff61e7..189f89a 100644 --- a/database/sql/pargar_sync_logs_amount_widen.sql +++ b/database/sql/pargar_sync_logs_amount_widen.sql @@ -2,18 +2,10 @@ -- ستون pargar_sync_logs.amount با float() ساخته شده بود = DOUBLE(8,2) با سقف ۹۹۹٬۹۹۹٫۹۹ -- هر سند واریز/برداشت ریالی بزرگ‌تر از این سقف در insert خطا می‌داد و سند اصلاً ساخته نمی‌شد، -- پس پرگار واریز مشتری را نمی‌دید و در تطبیق مانده، موجودی ریالی کیف صفر می‌شد. +-- DECIMAL(20,2) برای مبالغ میلیاردی کافی است. -- idempotent: چند بار قابل اجرا -SET @db = DATABASE(); - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'pargar_sync_logs' - AND COLUMN_NAME = 'amount' AND UPPER(COLUMN_TYPE) <> 'DOUBLE') = 1, - 'ALTER TABLE `pargar_sync_logs` MODIFY `amount` DOUBLE NULL', - 'SELECT ''skip pargar_sync_logs.amount'' AS info' -); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE `pargar_sync_logs` MODIFY `amount` DECIMAL(20, 2) NULL; SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS