*/ private array $seenFingerprints = []; public function __construct( private readonly PargarSyncService $sync, private readonly WalletBroadcastService $walletBroadcast ) { } /** * @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'] : []; $accountId = trim((string) ($data['accountId'] ?? '')); $result = [ 'applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => [], ]; if ($transactions === []) { return $result; } $lock = Cache::lock('pargar-wallet-apply:'.$customer->id, 60); if (! $lock->get()) { $result['errors'][] = 'همگام‌سازی دیگری برای این مشتری در حال اجراست'; return $result; } try { $walletApplied = false; foreach ($transactions as $tx) { if (! is_array($tx)) { continue; } $fingerprint = $this->transactionFingerprint($accountId, $tx); if ($fingerprint === '' || $this->isFingerprintApplied($fingerprint)) { $result['skipped']++; continue; } // علامت در حافظه تا در همین درخواست دوباره نخورد $this->seenFingerprints[$fingerprint] = true; $goldDelta = $this->transactionGoldDelta($tx); $rialDelta = $this->transactionRialDelta($tx); if (abs($goldDelta) < 0.01 && abs($rialDelta) < 1) { $this->recordProcessedFingerprint($customer, $fingerprint, $accountId, $tx, false, 'zero_amount'); $result['skipped']++; continue; } if ($this->isEchoOfRecentOutbound((int) $customer->id, $goldDelta, $rialDelta)) { $this->recordProcessedFingerprint($customer, $fingerprint, $accountId, $tx, false, 'echo_outbound'); $result['skipped']++; continue; } try { $this->applyTransactionDelta($customer, $tx, $goldDelta, $rialDelta); $walletApplied = true; $summary = $this->sync->summarizeInboundDocument([ 'accountId' => $accountId, 'accountName' => $data['accountName'] ?? null, 'transactions' => [$tx], ]); $log = PargarSyncLog::create([ 'customer_id' => $customer->id, 'amount' => $summary['amount'], 'desc' => $summary['desc'] !== '' ? $summary['desc'] : 'اعمال سند پرگار', 'type' => PargarSyncService::TYPE_DOCUMENT, 'sync_status' => PargarSyncService::STATUS_SUCCESS, 'pargar_ref' => $fingerprint, 'data' => json_encode([ 'accountId' => $accountId, 'accountName' => $data['accountName'] ?? null, 'transaction' => $tx, 'wallet_applied' => true, 'fingerprint' => $fingerprint, 'gold_delta' => $goldDelta, 'rial_delta' => $rialDelta, ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), ]); $result['applied']++; $result['logs'][] = (int) $log->id; } catch (\Throwable $e) { unset($this->seenFingerprints[$fingerprint]); Log::warning('Pargar inbound document apply failed', [ 'customer_id' => $customer->id, 'fingerprint' => $fingerprint, 'error' => $e->getMessage(), ]); $result['errors'][] = 'سند: '.$e->getMessage(); } } if ($walletApplied && $broadcast) { $this->walletBroadcast->broadcastForCustomer( (int) $customer->id, (int) ($customer->type ?? 2) ); } } finally { $lock->release(); } return $result; } /** * دکمهٔ ادمین: فقط اختلاف ماندهٔ پرگار را یک‌بار اعمال می‌کند (نه replay اسناد). * * @return array{ * documents: array{applied: int, skipped: int, errors: list, logs: list}, * mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} * } */ public function syncWalletFromPargar(Customer $customer, bool $forceMande = false): array { $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 90); if (! $lock->get()) { return [ 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => ['همگام‌سازی در حال اجراست'], 'logs' => []], 'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست — لطفاً صبر کنید'], ]; } try { // عمداً replay اسناد قدیمی نداریم — باعث شارژ چندباره می‌شد return [ 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []], 'mande' => $this->applyLatestMandeDifference($customer, force: $forceMande), ]; } finally { $lock->release(); } } /** * @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(); $this->walletBroadcast->broadcastForCustomer((int) $customer->id, $ownerType); 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)); } public function isFingerprintApplied(string $fingerprint): bool { if ($fingerprint === '' || isset($this->seenFingerprints[$fingerprint])) { return true; } return PargarSyncLog::query() ->where('type', PargarSyncService::TYPE_DOCUMENT) ->where('pargar_ref', $fingerprint) ->where('sync_status', PargarSyncService::STATUS_SUCCESS) ->exists(); } /** * @param array $tx */ private function recordProcessedFingerprint( Customer $customer, string $fingerprint, string $accountId, array $tx, bool $walletApplied, string $reason ): void { if ($this->isFingerprintApplied($fingerprint) && ! isset($this->seenFingerprints[$fingerprint])) { return; } // اگر فقط در حافظه است و هنوز در DB نیست، برای skipها هم رکورد بساز تا تکرار نشود if (PargarSyncLog::query() ->where('type', PargarSyncService::TYPE_DOCUMENT) ->where('pargar_ref', $fingerprint) ->exists()) { return; } PargarSyncLog::create([ 'customer_id' => $customer->id, 'desc' => $walletApplied ? 'اعمال سند پرگار' : ($reason === 'echo_outbound' ? 'سند پرگار — قبلاً از پنل ارسال شده (بدون اعمال مجدد)' : 'سند پرگار — رد شده'), 'type' => PargarSyncService::TYPE_DOCUMENT, 'sync_status' => PargarSyncService::STATUS_SUCCESS, 'pargar_ref' => $fingerprint, 'data' => json_encode([ 'accountId' => $accountId, 'transaction' => $tx, 'wallet_applied' => $walletApplied, 'fingerprint' => $fingerprint, 'skip_reason' => $walletApplied ? null : $reason, ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), ]); } /** * @param array $tx */ private function applyTransactionDelta(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' => $title, '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 ); } /** * @param array $tx */ private function transactionGoldDelta(array $tx): float { return $this->parseNumber($tx['besGold'] ?? 0) - $this->parseNumber($tx['bedGold'] ?? 0); } /** * @param array $tx */ private function transactionRialDelta(array $tx): float { return $this->parseNumber($tx['besRial'] ?? 0) - $this->parseNumber($tx['bedRial'] ?? 0); } private function isEchoOfRecentOutbound(int $customerId, float $goldDelta, float $rialDelta): bool { $since = now()->subDays(3); $logs = PargarSyncLog::query() ->where('customer_id', $customerId) ->whereIn('type', [PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD]) ->where('created_at', '>=', $since) ->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); } }