diff --git a/app/Http/Controllers/Admin/PargarController.php b/app/Http/Controllers/Admin/PargarController.php index d2fc199..0516a63 100644 --- a/app/Http/Controllers/Admin/PargarController.php +++ b/app/Http/Controllers/Admin/PargarController.php @@ -255,7 +255,7 @@ class PargarController extends Controller return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); } - public function syncCustomerWallet($customerId) + public function syncCustomerWallet(Request $request, $customerId) { if (! app(ConfigService::class)->isPargarAccounting()) { return response()->json([ @@ -272,7 +272,8 @@ class PargarController extends Controller ], 422); } - $result = app(PargarInboundWalletService::class)->syncWalletFromPargar($customer); + $force = $request->boolean('force'); + $result = app(PargarInboundWalletService::class)->syncWalletFromPargar($customer, $force); $appliedDocs = (int) ($result['documents']['applied'] ?? 0); $appliedMande = (bool) ($result['mande']['applied'] ?? false); $mandeMessage = (string) ($result['mande']['message'] ?? ''); @@ -285,7 +286,6 @@ class PargarController extends Controller } elseif ($appliedMande) { $message = 'ماندهٔ پرگار روی کیف اعمال شد'; } else { - // فقط وقتی هیچ‌چیز اعمال نشده، پیام رد/بدون‌اختلاف مانده را نشان بده $message = $mandeMessage !== '' ? $mandeMessage : 'سند قابل‌اعمالی یافت نشد'; @@ -296,7 +296,6 @@ class PargarController extends Controller 'message' => $message, 'result' => $result, ]); - // همیشه ۲۰۰ — «قبلاً اعمال شده / بدون اختلاف» خطا نیست؛ فرانت خودش info می‌زند } private function uniqueReferralId(): string diff --git a/app/Services/PargarInboundWalletService.php b/app/Services/PargarInboundWalletService.php index a261f61..5426f2b 100644 --- a/app/Services/PargarInboundWalletService.php +++ b/app/Services/PargarInboundWalletService.php @@ -4,17 +4,24 @@ namespace App\Services; use App\Models\Customer; use App\Models\PargarSyncLog; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Rooyesh\Wallet\interfaces\TransactionInterface; use Rooyesh\Wallet\interfaces\WalletInterface; /** - * اعمال اسناد ورودی پرگار روی کیف اپ — سندبه‌سند، نه تعدیل bulk از mande. + * اعمال اسناد/ماندهٔ ورودی پرگار روی کیف اپ. + * + * دکمهٔ ادمین فقط مانده (mande) را یک‌بار تنظیم می‌کند. + * اسناد جدید از webhook با اثرانگشت یکتا اعمال می‌شوند — بدون replay انبوه لاگ‌های قدیمی. */ class PargarInboundWalletService { public const TX_TYPE_PARGAR = 22; + /** @var array */ + private array $seenFingerprints = []; + public function __construct( private readonly PargarSyncService $sync, private readonly WalletBroadcastService $walletBroadcast @@ -25,7 +32,7 @@ class PargarInboundWalletService * @param array $data * @return array{applied: int, skipped: int, errors: list, logs: list} */ - public function applyDocuments(Customer $customer, array $data): array + public function applyDocuments(Customer $customer, array $data, bool $broadcast = true): array { $transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : []; $accountId = trim((string) ($data['accountId'] ?? '')); @@ -41,136 +48,132 @@ class PargarInboundWalletService return $result; } - $walletApplied = false; + $lock = Cache::lock('pargar-wallet-apply:'.$customer->id, 60); + if (! $lock->get()) { + $result['errors'][] = 'همگام‌سازی دیگری برای این مشتری در حال اجراست'; - foreach ($transactions as $tx) { - if (! is_array($tx)) { - continue; - } - - $docId = trim((string) ($tx['docId'] ?? '')); - if ($docId === '') { - $result['skipped']++; - continue; - } - - if ($this->isDocumentApplied($docId)) { - $result['skipped']++; - continue; - } - - $goldDelta = $this->transactionGoldDelta($tx); - $rialDelta = $this->transactionRialDelta($tx); - - if (abs($goldDelta) < 0.01 && abs($rialDelta) < 1) { - $result['skipped']++; - continue; - } - - if ($this->isEchoOfRecentOutbound((int) $customer->id, $goldDelta, $rialDelta)) { - $this->recordSkippedDocument($customer, $docId, $accountId, $tx, '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' => $docId, - 'data' => json_encode([ - 'accountId' => $accountId, - 'accountName' => $data['accountName'] ?? null, - 'transaction' => $tx, - 'wallet_applied' => true, - 'gold_delta' => $goldDelta, - 'rial_delta' => $rialDelta, - ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), - ]); - - $result['applied']++; - $result['logs'][] = (int) $log->id; - } catch (\Throwable $e) { - Log::warning('Pargar inbound document apply failed', [ - 'customer_id' => $customer->id, - 'doc_id' => $docId, - 'error' => $e->getMessage(), - ]); - $result['errors'][] = "سند {$docId}: " . $e->getMessage(); - } + return $result; } - if ($walletApplied) { - $this->walletBroadcast->broadcastForCustomer( - (int) $customer->id, - (int) ($customer->type ?? 2) - ); + 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; } /** - * اسناد ذخیره‌شدهٔ قبلی (قبل از deploy) را از لاگ دوباره اعمال می‌کند. + * دکمهٔ ادمین: فقط اختلاف ماندهٔ پرگار را یک‌بار اعمال می‌کند (نه replay اسناد). * - * @return array{applied: int, skipped: int, errors: list, logs: list} + * @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 replayStoredDocuments(Customer $customer): array + public function syncWalletFromPargar(Customer $customer, bool $forceMande = false): array { - $accountId = trim((string) ($customer->pargar_account_id ?? '')); - - $logs = PargarSyncLog::query() - ->where('type', PargarSyncService::TYPE_DOCUMENT) - ->where(function ($q) use ($customer, $accountId) { - $q->where('customer_id', $customer->id); - if ($accountId !== '') { - $q->orWhere('pargar_ref', $accountId); - } - }) - ->orderBy('id') - ->get(); - - $total = [ - 'applied' => 0, - 'skipped' => 0, - 'errors' => [], - 'logs' => [], - ]; - - foreach ($logs as $log) { - $data = json_decode((string) $log->data, true); - if (! is_array($data) || empty($data['transactions']) || ! is_array($data['transactions'])) { - continue; - } - - $batch = $this->applyDocuments($customer, $data); - $total['applied'] += $batch['applied']; - $total['skipped'] += $batch['skipped']; - $total['errors'] = array_merge($total['errors'], $batch['errors']); - $total['logs'] = array_merge($total['logs'], $batch['logs']); + $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 90); + if (! $lock->get()) { + return [ + 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => ['همگام‌سازی در حال اجراست'], 'logs' => []], + 'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست — لطفاً صبر کنید'], + ]; } - return $total; + 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): array + public function applyLatestMandeDifference(Customer $customer, bool $force = false): array { if ($this->hasPendingOutboundWalletSync((int) $customer->id)) { return [ @@ -190,7 +193,7 @@ class PargarInboundWalletService } $data = json_decode((string) $log->data, true) ?: []; - if (! empty($data['wallet_applied_from_mande'])) { + if (! $force && ! empty($data['wallet_applied_from_mande'])) { return ['applied' => false, 'message' => 'ماندهٔ پرگار قبلاً روی کیف اعمال شده است']; } @@ -214,7 +217,7 @@ class PargarInboundWalletService $input = [ 'title' => 'تطبیق ماندهٔ پرگار', - 'note' => 'mande-log:' . $log->id, + 'note' => 'mande-log:'.$log->id, 'expire_at' => now(), 'active' => true, ]; @@ -236,6 +239,7 @@ class PargarInboundWalletService $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(); @@ -251,39 +255,79 @@ class PargarInboundWalletService } /** - * @return array{ - * documents: array{applied: int, skipped: int, errors: list, logs: list}, - * mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} - * } + * @param array $tx */ - public function syncWalletFromPargar(Customer $customer): array + public function transactionFingerprint(string $accountId, array $tx): string { - return [ - 'documents' => $this->replayStoredDocuments($customer), - 'mande' => $this->applyLatestMandeDifference($customer), + // بدون 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 isDocumentApplied(string $docId): bool + public function isFingerprintApplied(string $fingerprint): bool { - if ($docId === '') { - return false; + if ($fingerprint === '' || isset($this->seenFingerprints[$fingerprint])) { + return true; } - $logs = PargarSyncLog::query() + return PargarSyncLog::query() ->where('type', PargarSyncService::TYPE_DOCUMENT) - ->where('pargar_ref', $docId) + ->where('pargar_ref', $fingerprint) ->where('sync_status', PargarSyncService::STATUS_SUCCESS) - ->get(['data']); + ->exists(); + } - foreach ($logs as $log) { - $data = json_decode((string) $log->data, true); - if (($data['wallet_applied'] ?? false) === true) { - return true; - } + /** + * @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; } - return false; + // اگر فقط در حافظه است و هنوز در 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), + ]); } /** @@ -326,10 +370,7 @@ class PargarInboundWalletService */ private function transactionGoldDelta(array $tx): float { - $bes = $this->parseNumber($tx['besGold'] ?? 0); - $bed = $this->parseNumber($tx['bedGold'] ?? 0); - - return $bes - $bed; + return $this->parseNumber($tx['besGold'] ?? 0) - $this->parseNumber($tx['bedGold'] ?? 0); } /** @@ -337,10 +378,7 @@ class PargarInboundWalletService */ private function transactionRialDelta(array $tx): float { - $bes = $this->parseNumber($tx['besRial'] ?? 0); - $bed = $this->parseNumber($tx['bedRial'] ?? 0); - - return $bes - $bed; + return $this->parseNumber($tx['besRial'] ?? 0) - $this->parseNumber($tx['bedRial'] ?? 0); } private function isEchoOfRecentOutbound(int $customerId, float $goldDelta, float $rialDelta): bool @@ -375,31 +413,6 @@ class PargarInboundWalletService return false; } - /** - * @param array $tx - */ - private function recordSkippedDocument( - Customer $customer, - string $docId, - string $accountId, - array $tx, - string $reason - ): void { - PargarSyncLog::create([ - 'customer_id' => $customer->id, - 'desc' => 'سند پرگار — قبلاً از پنل ارسال شده (بدون اعمال مجدد)', - 'type' => PargarSyncService::TYPE_DOCUMENT, - 'sync_status' => PargarSyncService::STATUS_SUCCESS, - 'pargar_ref' => $docId, - 'data' => json_encode([ - 'accountId' => $accountId, - 'transaction' => $tx, - 'wallet_applied' => false, - 'skip_reason' => $reason, - ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), - ]); - } - private function hasPendingOutboundWalletSync(int $customerId): bool { return PargarSyncLog::query() @@ -409,6 +422,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 === '') {