*/ private array $seenFingerprints = []; public function __construct( private readonly WalletBroadcastService $walletBroadcast ) { } public static function isApplyFrozen(): bool { return (bool) Cache::get(self::KILL_SWITCH_CACHE_KEY, false); } public static function freezeApplies(bool $frozen = true): void { Cache::forever(self::KILL_SWITCH_CACHE_KEY, $frozen); } /** * دکمهٔ ادمین: * ۱) اسناد لاگ‌شدهٔ اعمال‌نشده برای همین مشتری * ۲) سپس هم‌ترازی مانده (mande) * * @return array{ * 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 { unset($forceMande); // مانده در دکمه همیشه force می‌شود if (self::isApplyFrozen()) { return [ 'documents' => ['applied' => 0, 'skipped' => 0, 'batches' => 0, 'errors' => []], 'mande' => [ 'applied' => false, 'message' => 'اعمال کیف یخ‌زده است — php artisan pargar:wallet-freeze off', ], ]; } $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 120); if (! $lock->get()) { return [ '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' => $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 ($this->hasPendingOutboundWalletSync((int) $customer->id)) { return [ 'applied' => false, 'message' => 'سند ارسالی از پنل هنوز تأیید نشده — مانده اعمال نشد تا شارژ برنگردد', ]; } $log = PargarSyncLog::query() ->where('customer_id', $customer->id) ->where('type', PargarSyncService::TYPE_MANDE) ->orderByDesc('id') ->first(); if ($log === null) { return ['applied' => false, 'message' => 'لاگ ماندهٔ پرگار برای این مشتری نیست']; } $data = json_decode((string) $log->data, true) ?: []; if (! $force && ! empty($data['wallet_applied_from_mande'])) { return ['applied' => false, 'message' => 'مانده قبلاً هم‌تراز شده است']; } $goldInv = $this->parseNumber($data['GoldInv'] ?? 0); $rialInv = $this->parseNumber($data['RialInv'] ?? 0); $ownerType = (int) ($customer->type ?? 2); $walletInterface = new WalletInterface((int) $customer->id, $ownerType); $wallet = $walletInterface->wallet; $goldDiff = $goldInv - (float) ($wallet->gold ?? 0); $rialDiff = $rialInv - (float) ($wallet->cash ?? 0); if (abs($goldDiff) < 0.01 && abs($rialDiff) < 1) { $data['wallet_applied_from_mande'] = true; $log->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); $log->save(); return ['applied' => false, 'message' => 'مانده با پرگار یکسان است']; } $input = [ 'title' => 'تطبیق ماندهٔ پرگار', 'note' => 'mande-log:'.$log->id, 'expire_at' => now(), 'active' => true, ]; if (abs($goldDiff) >= 0.01) { $input['gold'] = abs($goldDiff); } if (abs($rialDiff) >= 1) { $input['cash'] = abs($rialDiff); } new TransactionInterface( $walletInterface, $input, self::TX_TYPE_PARGAR, $goldDiff > 0, $rialDiff > 0 ); $data['wallet_applied_from_mande'] = true; $data['applied_gold_delta'] = $goldDiff; $data['applied_rial_delta'] = $rialDiff; $data['applied_at'] = now()->toDateTimeString(); $log->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); $log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ'; $log->save(); return [ 'applied' => true, 'gold_delta' => $goldDiff, 'rial_delta' => $rialDiff, '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() ->where('customer_id', $customerId) ->whereIn('type', [PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD]) ->where('sync_status', PargarSyncService::STATUS_PENDING) ->exists(); } private function normalizeNumberString(mixed $value): string { return (string) $this->parseNumber($value); } private function parseNumber(mixed $value): float { if ($value === null || $value === '') { return 0.0; } return (float) str_replace('/', '.', (string) $value); } }