From 468fb95a571d916acea8633c35ee6537583e43de Mon Sep 17 00:00:00 2001 From: adonis Date: Tue, 21 Jul 2026 14:33:16 +0330 Subject: [PATCH] fix pargar bugs 7 --- .../Controllers/Admin/PargarController.php | 15 +- app/Services/PargarInboundWalletService.php | 360 +++++++++++++++--- 2 files changed, 315 insertions(+), 60 deletions(-) diff --git a/app/Http/Controllers/Admin/PargarController.php b/app/Http/Controllers/Admin/PargarController.php index 0516a63..58bf8e9 100644 --- a/app/Http/Controllers/Admin/PargarController.php +++ b/app/Http/Controllers/Admin/PargarController.php @@ -275,20 +275,27 @@ class PargarController extends Controller $force = $request->boolean('force'); $result = app(PargarInboundWalletService::class)->syncWalletFromPargar($customer, $force); $appliedDocs = (int) ($result['documents']['applied'] ?? 0); + $skippedDocs = (int) ($result['documents']['skipped'] ?? 0); + $batches = (int) ($result['documents']['batches'] ?? 0); $appliedMande = (bool) ($result['mande']['applied'] ?? false); $mandeMessage = (string) ($result['mande']['message'] ?? ''); $ok = $appliedDocs > 0 || $appliedMande; if ($appliedDocs > 0 && $appliedMande) { - $message = "{$appliedDocs} سند و ماندهٔ پرگار روی کیف اعمال شد"; + $message = "{$appliedDocs} سند اعمال شد و مانده هم‌تراز شد"; } elseif ($appliedDocs > 0) { - $message = "{$appliedDocs} سند از پرگار روی کیف اعمال شد"; + $message = "{$appliedDocs} سند اعمال شد" + .($mandeMessage !== '' ? " — {$mandeMessage}" : ''); } elseif ($appliedMande) { - $message = 'ماندهٔ پرگار روی کیف اعمال شد'; + $message = 'مانده با پرگار هم‌تراز شد'; + } elseif ($batches > 0 && $skippedDocs > 0) { + $message = $mandeMessage !== '' + ? $mandeMessage + : 'اسناد تکراری بودند؛ تغییری لازم نبود'; } else { $message = $mandeMessage !== '' ? $mandeMessage - : 'سند قابل‌اعمالی یافت نشد'; + : 'سند یا ماندهٔ جدیدی برای اعمال نبود'; } return response()->json([ diff --git a/app/Services/PargarInboundWalletService.php b/app/Services/PargarInboundWalletService.php index cefa5ea..897c0c5 100644 --- a/app/Services/PargarInboundWalletService.php +++ b/app/Services/PargarInboundWalletService.php @@ -10,33 +10,34 @@ use Rooyesh\Wallet\interfaces\TransactionInterface; use Rooyesh\Wallet\interfaces\WalletInterface; /** - * اعمال اسناد/ماندهٔ ورودی پرگار روی کیف اپ. + * همگام‌سازی کیف با پرگار — فقط با دکمهٔ ادمین. * - * اضطراری: اعمال اسناد روی کیف به‌طور کامل خاموش است تا از شارژ چندباره جلوگیری شود. - * دکمهٔ ادمین فقط مانده را (در صورت فعال بودن) یک‌بار تنظیم می‌کند. + * قواعد سخت: + * 1) webhook اسناد (/documents) هرگز کیف را عوض نمی‌کند — فقط لاگ. + * 2) دکمه: اول اسنادِ اعمال‌نشدهٔ همان مشتری، بعد مانده. + * 3) هر سطر سند حداکثر یک‌بار (اثرانگشت). + * 4) هر لاگ دسته حداکثر یک‌بار پردازش می‌شود (wallet_batch_applied). */ class PargarInboundWalletService { public const TX_TYPE_PARGAR = 22; - /** کلید کش برای قطع اضطراری هرگونه اعمال کیف از پرگار */ public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled'; + /** سقف تعداد دستهٔ سند در هر کلیک (جلوگیری از timeout) */ + private const MAX_BATCHES_PER_SYNC = 30; + + /** @var array */ + private array $seenFingerprints = []; + public function __construct( - private readonly PargarSyncService $sync, private readonly WalletBroadcastService $walletBroadcast ) { } public static function isApplyFrozen(): bool { - // پیش‌فرض: یخ‌زده — مگر صریحاً false شده باشد - $cached = Cache::get(self::KILL_SWITCH_CACHE_KEY); - if ($cached === null) { - return true; - } - - return (bool) $cached; + return (bool) Cache::get(self::KILL_SWITCH_CACHE_KEY, false); } public static function freezeApplies(bool $frozen = true): void @@ -45,78 +46,186 @@ class PargarInboundWalletService } /** - * @param array $data - * @return array{applied: int, skipped: int, errors: list, logs: list} - */ - public function applyDocuments(Customer $customer, array $data, bool $broadcast = true): array - { - $transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : []; - - Log::warning('Pargar applyDocuments blocked (frozen)', [ - 'customer_id' => $customer->id, - 'tx_count' => count($transactions), - ]); - - return [ - 'applied' => 0, - 'skipped' => count($transactions), - 'errors' => ['اعمال اسناد پرگار روی کیف موقتاً غیرفعال است'], - 'logs' => [], - ]; - } - - /** + * دکمهٔ ادمین: + * ۱) اسناد لاگ‌شدهٔ اعمال‌نشده برای همین مشتری + * ۲) سپس هم‌ترازی مانده (mande) + * * @return array{ - * documents: array{applied: int, skipped: int, errors: list, logs: list}, + * documents: array{applied: int, skipped: int, batches: int, errors: list}, * mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} * } */ public function syncWalletFromPargar(Customer $customer, bool $forceMande = false): array { - if (self::isApplyFrozen() && ! $forceMande) { + unset($forceMande); // مانده در دکمه همیشه force می‌شود + + if (self::isApplyFrozen()) { return [ - 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []], + 'documents' => ['applied' => 0, 'skipped' => 0, 'batches' => 0, 'errors' => []], 'mande' => [ 'applied' => false, - 'message' => 'اعمال کیف از پرگار یخ‌زده است — ابتدا با دستور pargar:wallet-freeze off آزاد کنید', + 'message' => 'اعمال کیف یخ‌زده است — php artisan pargar:wallet-freeze off', ], ]; } - $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 90); + $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 120); if (! $lock->get()) { return [ - 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => ['همگام‌سازی در حال اجراست'], 'logs' => []], - 'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست — لطفاً صبر کنید'], + 'documents' => ['applied' => 0, 'skipped' => 0, 'batches' => 0, 'errors' => ['همگام‌سازی در حال اجراست']], + 'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست'], ]; } try { + // ۱) اسناد لاگ‌شده که هنوز روی کیف نرفته‌اند + $documents = $this->applyPendingDocumentBatches($customer); + + // ۲) همیشه مانده را با آخرین لاگ mande هم‌تراز کن (حتی اگر سندی نبود) + // force تا بعد از اعمال اسناد، موجودی متورم به ماندهٔ واقعی پرگار برگردد + $mande = $this->applyLatestMandeDifference($customer, force: true); + + if ($documents['applied'] > 0 || ($mande['applied'] ?? false)) { + $this->walletBroadcast->broadcastForCustomer( + (int) $customer->id, + (int) ($customer->type ?? 2) + ); + } + return [ - 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []], - 'mande' => $this->applyLatestMandeDifference($customer, force: $forceMande), + 'documents' => $documents, + 'mande' => $mande, ]; } finally { $lock->release(); } } + /** + * فقط لاگ‌های دستهٔ ورودی که هنوز روی کیف اعمال نشده‌اند. + * + * @return array{applied: int, skipped: int, batches: int, errors: list} + */ + public function applyPendingDocumentBatches(Customer $customer): array + { + $result = [ + 'applied' => 0, + 'skipped' => 0, + 'batches' => 0, + 'errors' => [], + ]; + + $batches = PargarSyncLog::query() + ->where('type', PargarSyncService::TYPE_DOCUMENT) + ->where('customer_id', $customer->id) + ->orderBy('id') + ->limit(200) + ->get(); + + foreach ($batches as $batch) { + if ($result['batches'] >= self::MAX_BATCHES_PER_SYNC) { + break; + } + + $data = json_decode((string) $batch->data, true); + if (! is_array($data)) { + continue; + } + + // فقط دسته‌های ورودی پرگار (آرایه transactions) — نه رکورد تک‌سند اعمال‌شده + if (empty($data['transactions']) || ! is_array($data['transactions'])) { + continue; + } + + if (! empty($data['wallet_batch_applied']) || ! empty($data['wallet_applied'])) { + continue; + } + + $result['batches']++; + $accountId = trim((string) ($data['accountId'] ?? $customer->pargar_account_id ?? '')); + + foreach ($data['transactions'] as $tx) { + if (! is_array($tx)) { + continue; + } + + $outcome = $this->applyOneTransaction($customer, $accountId, $tx); + if ($outcome === 'applied') { + $result['applied']++; + } elseif ($outcome === 'skipped') { + $result['skipped']++; + } else { + $result['errors'][] = $outcome; + } + } + + // حتی اگر همه skip شدند، دسته را مصرف‌شده علامت بزن تا دوباره نیاید + $data['wallet_batch_applied'] = true; + $data['wallet_batch_applied_at'] = now()->toDateTimeString(); + $batch->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + if (is_string($batch->desc) && ! str_contains($batch->desc, '[اعمال‌شده]')) { + $batch->desc = '[اعمال‌شده] '.$batch->desc; + } + $batch->save(); + } + + return $result; + } + + /** + * @param array $tx + * @return 'applied'|'skipped'|string error + */ + private function applyOneTransaction(Customer $customer, string $accountId, array $tx): string + { + $fingerprint = $this->transactionFingerprint($accountId, $tx); + if ($fingerprint === '' || $this->isFingerprintUsed($fingerprint)) { + return 'skipped'; + } + + $this->seenFingerprints[$fingerprint] = true; + + $goldDelta = $this->parseNumber($tx['besGold'] ?? 0) - $this->parseNumber($tx['bedGold'] ?? 0); + $rialDelta = $this->parseNumber($tx['besRial'] ?? 0) - $this->parseNumber($tx['bedRial'] ?? 0); + + if (abs($goldDelta) < 0.01 && abs($rialDelta) < 1) { + $this->rememberFingerprint($customer, $fingerprint, $accountId, $tx, false, 'zero'); + + return 'skipped'; + } + + if ($this->isEchoOfRecentOutbound((int) $customer->id, $goldDelta, $rialDelta)) { + $this->rememberFingerprint($customer, $fingerprint, $accountId, $tx, false, 'echo'); + + return 'skipped'; + } + + try { + $this->writeWalletDelta($customer, $tx, $goldDelta, $rialDelta); + $this->rememberFingerprint($customer, $fingerprint, $accountId, $tx, true, null, $goldDelta, $rialDelta); + + return 'applied'; + } catch (\Throwable $e) { + unset($this->seenFingerprints[$fingerprint]); + Log::warning('Pargar applyOneTransaction failed', [ + 'customer_id' => $customer->id, + 'fingerprint' => $fingerprint, + 'error' => $e->getMessage(), + ]); + + return $e->getMessage(); + } + } + /** * @return array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} */ public function applyLatestMandeDifference(Customer $customer, bool $force = false): array { - if (self::isApplyFrozen() && ! $force) { - return [ - 'applied' => false, - 'message' => 'اعمال کیف از پرگار یخ‌زده است', - ]; - } - if ($this->hasPendingOutboundWalletSync((int) $customer->id)) { return [ 'applied' => false, - 'message' => 'سند ارسالی از پنل به پرگار هنوز تأیید نشده — برای جلوگیری از برگشت شارژ، ماندهٔ پرگار اعمال نشد', + 'message' => 'سند ارسالی از پنل هنوز تأیید نشده — مانده اعمال نشد تا شارژ برنگردد', ]; } @@ -127,12 +236,12 @@ class PargarInboundWalletService ->first(); if ($log === null) { - return ['applied' => false, 'message' => 'لاگ ماندهٔ پرگار برای این مشتری یافت نشد']; + return ['applied' => false, 'message' => 'لاگ ماندهٔ پرگار برای این مشتری نیست']; } $data = json_decode((string) $log->data, true) ?: []; if (! $force && ! empty($data['wallet_applied_from_mande'])) { - return ['applied' => false, 'message' => 'ماندهٔ پرگار قبلاً روی کیف اعمال شده است']; + return ['applied' => false, 'message' => 'مانده قبلاً هم‌تراز شده است']; } $goldInv = $this->parseNumber($data['GoldInv'] ?? 0); @@ -150,7 +259,7 @@ class PargarInboundWalletService $log->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); $log->save(); - return ['applied' => false, 'message' => 'اختلافی بین ماندهٔ پرگار و کیف اپ نیست']; + return ['applied' => false, 'message' => 'مانده با پرگار یکسان است']; } $input = [ @@ -182,16 +291,150 @@ class PargarInboundWalletService $log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ'; $log->save(); - $this->walletBroadcast->broadcastForCustomer((int) $customer->id, $ownerType); - return [ 'applied' => true, 'gold_delta' => $goldDiff, 'rial_delta' => $rialDiff, - 'message' => 'ماندهٔ پرگار روی کیف اعمال شد', + 'message' => 'مانده با پرگار هم‌تراز شد', ]; } + /** + * @param array $tx + */ + public function transactionFingerprint(string $accountId, array $tx): string + { + // بدون اتکا به docId یکتای پرگار — محتوایی + $parts = [ + $accountId, + trim((string) ($tx['date'] ?? '')), + trim((string) ($tx['docType'] ?? '')), + trim((string) ($tx['desc'] ?? '')), + $this->normalizeNumberString($tx['bedGold'] ?? 0), + $this->normalizeNumberString($tx['besGold'] ?? 0), + $this->normalizeNumberString($tx['bedRial'] ?? 0), + $this->normalizeNumberString($tx['besRial'] ?? 0), + ]; + + return 'fp:'.hash('sha256', implode('|', $parts)); + } + + private function isFingerprintUsed(string $fingerprint): bool + { + if ($fingerprint === '' || isset($this->seenFingerprints[$fingerprint])) { + return true; + } + + return PargarSyncLog::query() + ->where('type', PargarSyncService::TYPE_DOCUMENT) + ->where('pargar_ref', $fingerprint) + ->exists(); + } + + /** + * @param array $tx + */ + private function rememberFingerprint( + Customer $customer, + string $fingerprint, + string $accountId, + array $tx, + bool $applied, + ?string $skipReason, + float $goldDelta = 0, + float $rialDelta = 0 + ): void { + if (PargarSyncLog::query() + ->where('type', PargarSyncService::TYPE_DOCUMENT) + ->where('pargar_ref', $fingerprint) + ->exists()) { + return; + } + + PargarSyncLog::create([ + 'customer_id' => $customer->id, + 'amount' => abs($rialDelta) >= 1 ? abs($rialDelta) : abs($goldDelta), + 'desc' => $applied + ? 'اعمال سند پرگار' + : ('رد سند پرگار'.($skipReason ? " ({$skipReason})" : '')), + 'type' => PargarSyncService::TYPE_DOCUMENT, + 'sync_status' => PargarSyncService::STATUS_SUCCESS, + 'pargar_ref' => $fingerprint, + 'data' => json_encode([ + 'accountId' => $accountId, + 'transaction' => $tx, + 'wallet_applied' => $applied, + 'fingerprint' => $fingerprint, + 'skip_reason' => $skipReason, + 'gold_delta' => $goldDelta, + 'rial_delta' => $rialDelta, + ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + ]); + } + + /** + * @param array $tx + */ + private function writeWalletDelta(Customer $customer, array $tx, float $goldDelta, float $rialDelta): void + { + $ownerType = (int) ($customer->type ?? 2); + $walletInterface = new WalletInterface((int) $customer->id, $ownerType); + + $desc = trim((string) ($tx['desc'] ?? '')); + $docType = trim((string) ($tx['docType'] ?? '')); + $title = $desc !== '' ? $desc : ($docType !== '' ? $docType : 'سند پرگار'); + + $input = [ + 'title' => mb_substr($title, 0, 180), + 'note' => trim((string) ($tx['docId'] ?? '')), + 'expire_at' => now(), + 'active' => true, + ]; + if (abs($goldDelta) >= 0.01) { + $input['gold'] = abs($goldDelta); + } + if (abs($rialDelta) >= 1) { + $input['cash'] = abs($rialDelta); + } + + new TransactionInterface( + $walletInterface, + $input, + self::TX_TYPE_PARGAR, + $goldDelta > 0, + $rialDelta > 0 + ); + } + + private function isEchoOfRecentOutbound(int $customerId, float $goldDelta, float $rialDelta): bool + { + $logs = PargarSyncLog::query() + ->where('customer_id', $customerId) + ->whereIn('type', [PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD]) + ->where('created_at', '>=', now()->subDays(3)) + ->whereIn('sync_status', [PargarSyncService::STATUS_PENDING, PargarSyncService::STATUS_SUCCESS]) + ->get(); + + foreach ($logs as $log) { + $payload = json_decode((string) $log->data, true) ?: []; + $signed = (float) ($payload['signed_amount'] ?? 0); + $kind = (string) ($payload['kind'] ?? ''); + + if ($kind === 'gold' && abs($goldDelta) >= 0.01 && abs(abs($signed) - abs($goldDelta)) < 0.01) { + if (($signed > 0 && $goldDelta > 0) || ($signed < 0 && $goldDelta < 0)) { + return true; + } + } + if ($kind === 'cash' && abs($rialDelta) >= 1 && abs(abs($signed) - abs($rialDelta)) < 1) { + if (($signed > 0 && $rialDelta > 0) || ($signed < 0 && $rialDelta < 0)) { + return true; + } + } + } + + return false; + } + private function hasPendingOutboundWalletSync(int $customerId): bool { return PargarSyncLog::query() @@ -201,6 +444,11 @@ class PargarInboundWalletService ->exists(); } + private function normalizeNumberString(mixed $value): string + { + return (string) $this->parseNumber($value); + } + private function parseNumber(mixed $value): float { if ($value === null || $value === '') {