where('customer_id', $customer->id) ->whereIn('type', [ PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD, PargarSyncService::TYPE_ORDER, ]) ->where('sync_status', PargarSyncService::STATUS_PENDING) ->exists(); } /** * دکمهٔ ادمین / همگام‌سازی خودکار: * ۱) علامت مصرف دسته‌های سند inbound (بدون نوشتن روی کیف) * ۲) هم‌ترازی مانده با آخرین لاگ mande * * @param bool $forceMande اصلاح دستی ادمین: گیت‌های اعتبارسنجی مانده رد شوند * @param bool $auto مسیر خودکار (webhook/صف) — همیشه با گیت کامل * @return array{ * documents: array{applied: int, skipped: int, batches: int, acknowledged: int, errors: list}, * mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} * } */ public function syncWalletFromPargar(Customer $customer, bool $forceMande = false, bool $auto = false): array { if (self::isApplyFrozen()) { return [ 'documents' => [ 'applied' => 0, 'skipped' => 0, 'batches' => 0, 'acknowledged' => 0, 'errors' => [], ], 'mande' => [ 'applied' => false, 'message' => 'اعمال کیف یخ‌زده است — php artisan pargar:wallet-freeze off', ], ]; } $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 60); if (! $lock->get()) { return [ 'documents' => [ 'applied' => 0, 'skipped' => 0, 'batches' => 0, 'acknowledged' => 0, 'errors' => ['همگام‌سازی در حال اجراست'], ], 'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست'], ]; } try { $this->attachOrphanDocumentLogs($customer); $this->attachOrphanMandeLogs($customer); // ۱) تاریخچهٔ اسناد inbound را روی کیف replay نکن — فقط مصرف‌شده علامت بزن $documents = $this->acknowledgePendingDocumentBatches($customer); // ۲) منبع حقیقت موجودی = ماندهٔ پرگار، اما فقط ماندهٔ معتبر $mande = $this->applyLatestMandeDifference( $customer, force: ! $auto && $forceMande ); if (($documents['acknowledged'] ?? 0) > 0 || ($mande['applied'] ?? false)) { $this->walletBroadcast->broadcastForCustomer( (int) $customer->id, (int) ($customer->type ?? 2) ); } return [ 'documents' => $documents, 'mande' => $mande, ]; } finally { $lock->release(); } } /** * دسته‌های ورودی پرگار را بدون تغییر کیف، مصرف‌شده علامت می‌زند. * (اعمال تک‌تک bed/bes تاریخچه = تورم کیف و 504) * * @return array{applied: int, skipped: int, batches: int, acknowledged: int, errors: list} */ public function acknowledgePendingDocumentBatches(Customer $customer): array { $result = [ 'applied' => 0, 'skipped' => 0, 'batches' => 0, 'acknowledged' => 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; } if (empty($data['transactions']) || ! is_array($data['transactions'])) { continue; } if (! empty($data['wallet_batch_applied']) || ! empty($data['wallet_applied'])) { continue; } $result['batches']++; $txCount = count($data['transactions']); $result['skipped'] += $txCount; try { $data['wallet_batch_ack_only'] = true; $data['wallet_batch_ack_reason'] = 'mande_is_source_of_truth'; $this->markDocumentBatchApplied($batch, $data); $result['acknowledged']++; } catch (\Throwable $e) { $result['errors'][] = 'علامت دسته #'.$batch->id.' ناموفق: '.$e->getMessage(); Log::error('Pargar acknowledgeDocumentBatch failed', [ 'log_id' => $batch->id, 'customer_id' => $customer->id, 'error' => $e->getMessage(), ]); } } return $result; } /** * @deprecated از sync دکمه حذف شد — فقط برای سازگاری احتمالی تست/کال‌های قدیمی * * @return array{applied: int, skipped: int, batches: int, errors: list} */ public function applyPendingDocumentBatches(Customer $customer): array { $ack = $this->acknowledgePendingDocumentBatches($customer); return [ 'applied' => 0, 'skipped' => (int) ($ack['skipped'] ?? 0), 'batches' => (int) ($ack['batches'] ?? 0), 'errors' => $ack['errors'] ?? [], ]; } /** * علامت مصرف دسته — بدون Pretty-Print؛ در صورت شکست ذخیره، stub فشرده می‌نویسد. * * @param array $data */ private function markDocumentBatchApplied(PargarSyncLog $batch, array $data): void { $data['wallet_batch_applied'] = true; $data['wallet_batch_applied_at'] = now()->toDateTimeString(); if (is_string($batch->desc) && ! str_contains($batch->desc, '[اعمال‌شده]')) { $batch->desc = mb_substr('[اعمال‌شده] '.$batch->desc, 0, 250); } // ack-only: همیشه stub فشرده — نیازی به نگه‌داشتن صدها سطر تاریخچه نیست if (! empty($data['wallet_batch_ack_only'])) { $batch->data = $this->encodeAppliedBatchCompact($data); $batch->save(); return; } $encoded = json_encode($data, JSON_UNESCAPED_UNICODE); if ($encoded === false) { throw new \RuntimeException('json_encode failed for pargar batch #'.$batch->id); } $batch->data = $encoded; try { $batch->save(); } catch (\Throwable $e) { $batch->data = $this->encodeAppliedBatchCompact($data); $batch->save(); } } /** * @param array $data */ private function encodeAppliedBatchCompact(array $data): string { $transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : []; return (string) json_encode([ 'accountName' => $data['accountName'] ?? null, 'accountId' => $data['accountId'] ?? null, 'transactions_count' => count($transactions), 'transactions_fp' => hash('sha256', (string) json_encode($transactions, JSON_UNESCAPED_UNICODE)), 'wallet_batch_applied' => true, 'wallet_batch_applied_at' => $data['wallet_batch_applied_at'] ?? now()->toDateTimeString(), 'wallet_batch_ack_only' => true, 'payload_compacted' => true, ], JSON_UNESCAPED_UNICODE); } /** * ماندهٔ پرگار روی کیف اپ: * RialInv → کیف ریالی، GoldInv → طلای کیف اصلی (main_wallet_products). * محصولات خارج از کیف اصلی (قلک سرمایه‌گذاری، سکه و …) هیچ سهمی در مانده ندارند و * هرگز از آن اثر نمی‌گیرند. * * @param bool $force اعمال دستی ادمین — گیت‌های اعتبارسنجی نادیده گرفته می‌شوند * @return array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} */ public function applyLatestMandeDifference(Customer $customer, bool $force = false): array { $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' => 'مانده قبلاً هم‌تراز شده است']; } if (! $force) { $block = $this->mandeBlockReason($customer, $log, $data); if ($block !== null) { $this->markMandeSkipped($log, $data, $block['reason']); return ['applied' => false, 'message' => $block['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; $mainGold = app(WalletBalanceService::class) ->effectiveMainWalletGold((int) $customer->id, $ownerType); $goldDiff = $goldInv - $mainGold; $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); $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, 0, [], true ); $data['wallet_applied_from_mande'] = true; $data['applied_gold_delta'] = $goldDiff; $data['applied_rial_delta'] = $rialDiff; $data['applied_at'] = now()->toDateTimeString(); $data['applied_forced'] = $force; $data['main_gold_before'] = $mainGold; unset($data['mande_skip_reason'], $data['mande_skipped_at']); $log->data = json_encode($data, JSON_UNESCAPED_UNICODE); $log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ'; $log->save(); Log::info('Pargar mande applied to wallet', [ 'customer_id' => (int) $customer->id, 'mande_log_id' => (int) $log->id, 'gold_delta' => $goldDiff, 'rial_delta' => $rialDiff, 'forced' => $force, ]); return [ 'applied' => true, 'gold_delta' => $goldDiff, 'rial_delta' => $rialDiff, 'message' => 'مانده با پرگار هم‌تراز شد', ]; } /** * ماندهٔ پرگار فقط وقتی معتبر است که همهٔ اسناد ما در پرگار ثبت شده باشند و * snapshot بعد از آن ثبت گرفته شده باشد. اعمال ماندهٔ قدیمی یعنی برگرداندن * موجودی مشتری به قبل از سند — همان «وزن/ریالم کم شد». * * @param array $data * @return array{reason: string, message: string}|null null یعنی معتبر */ private function mandeBlockReason(Customer $customer, PargarSyncLog $log, array $data): ?array { if ($this->hasPendingOutboundToPargar($customer)) { return [ 'reason' => 'pending_outbound', 'message' => 'سند پنل هنوز در پرگار ثبت نشده — مانده اعمال نمی‌شود', ]; } $lastConfirmedAt = $this->lastOutboundConfirmedAt($customer); $takenAt = $log->created_at; if ($lastConfirmedAt !== null && ($takenAt === null || $takenAt->lte($lastConfirmedAt))) { return [ 'reason' => 'taken_before_document', 'message' => 'این مانده پیش از ثبت آخرین سند گرفته شده — نادیده گرفته شد', ]; } $missing = $this->unexportedCashMovement($customer, $lastConfirmedAt); if ($missing !== null) { return [ 'reason' => 'unexported_wallet_movement', 'message' => 'حرکت ریالی ' . number_format($missing['amount']) . ' ریال هنوز سندی در پرگار ندارد — تا ثبت سند، مانده اعمال نمی‌شود', ]; } $previous = $this->previousComparableMande($log, $lastConfirmedAt); if ($previous === null) { return [ 'reason' => 'awaiting_second_snapshot', 'message' => 'منتظر ماندهٔ بعدی برای تأیید پایداری', ]; } if (! $this->mandeValuesMatch($data, $previous)) { return [ 'reason' => 'unstable_snapshot', 'message' => 'ماندهٔ پرگار هنوز در حال تغییر است — منتظر مقدار پایدار', ]; } if ($this->isSuspiciousZeroSnapshot($customer, $data)) { return [ 'reason' => 'zero_snapshot', 'message' => 'ماندهٔ پرگار صفر است ولی کیف مشتری خالی نیست — برای جلوگیری از صفر شدن اشتباه اعمال نشد', ]; } return null; } /** * واریز/برداشت ریالی که سند پرگارش ساخته نشده است. * * تا وقتی پرگار از این حرکت خبر ندارد، ماندهٔ آن هم شامل این پول نیست؛ اعمال آن * موجودی مشتری را پاک می‌کند. پس مانده را نگه می‌داریم تا سند ساخته و ثبت شود. * * @return array{amount: float, withdraw_ids: list}|null */ private function unexportedCashMovement(Customer $customer, ?Carbon $lastConfirmedAt): ?array { $since = $this->cashMovementScanStart($customer, $lastConfirmedAt); if ($since === null) { return null; } $rows = Transaction::query() ->where('customer_id', $customer->id) ->whereIn('type', self::CASH_DOCUMENT_TX_TYPES) ->whereNotNull('withdraw_id') ->where('created_at', '>', $since) ->where(function ($q) { $q->where('cash', '>=', 1)->orWhere('cash', '<=', -1); }) ->whereNotExists(function ($q) { $q->select(DB::raw(1)) ->from('pargar_sync_logs') ->whereColumn('pargar_sync_logs.withdraw_id', 'transactions.withdraw_id') ->where('pargar_sync_logs.type', PargarSyncService::TYPE_WALLET_CASH); }) ->get(['withdraw_id', 'cash']); if ($rows->isEmpty()) { return null; } return [ 'amount' => (float) $rows->sum(fn ($row) => abs((float) $row->cash)), 'withdraw_ids' => $rows->pluck('withdraw_id')->map(fn ($id) => (int) $id)->all(), ]; } /** * از کجا دنبال سند جاافتاده بگردیم: بعد از آخرین سند ثبت‌شده، و اگر نبود از * شروع اتصال همین مشتری به پرگار. تراکنش‌های قبل از اتصال سند ندارند و نباید * سینک را برای همیشه قفل کنند. */ private function cashMovementScanStart(Customer $customer, ?Carbon $lastConfirmedAt): ?Carbon { $firstLogAt = PargarSyncLog::query() ->where('customer_id', $customer->id) ->min('created_at'); $integrationStart = $firstLogAt !== null ? Carbon::parse($firstLogAt) : null; if ($lastConfirmedAt !== null && $integrationStart !== null) { return $lastConfirmedAt->greaterThan($integrationStart) ? $lastConfirmedAt : $integrationStart; } return $lastConfirmedAt ?? $integrationStart; } /** * ماندهٔ صفرِ هر دو فیلد برای مشتری‌ای که کیفش خالی نیست، تقریباً همیشه یعنی * پرگار حساب را پیدا نکرده — نه اینکه مشتری تسویه کرده باشد. * * @param array $data */ private function isSuspiciousZeroSnapshot(Customer $customer, array $data): bool { $goldInv = $this->parseNumber($data['GoldInv'] ?? 0); $rialInv = $this->parseNumber($data['RialInv'] ?? 0); if (abs($goldInv) >= 0.001 || abs($rialInv) >= 1) { return false; } $ownerType = (int) ($customer->type ?? 2); $mainGold = app(WalletBalanceService::class) ->effectiveMainWalletGold((int) $customer->id, $ownerType); $cash = (float) ((new WalletInterface((int) $customer->id, $ownerType))->wallet->cash ?? 0); return abs($mainGold) >= 0.01 || abs($cash) >= 1; } /** * آخرین لحظه‌ای که پرگار یکی از اسناد ما را ثبت کرده است. */ private function lastOutboundConfirmedAt(Customer $customer): ?Carbon { if (! PargarSyncLog::hasConfirmedAtColumn()) { return null; } $value = PargarSyncLog::query() ->where('customer_id', $customer->id) ->whereIn('type', [ PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD, PargarSyncService::TYPE_ORDER, ]) ->whereNotNull('confirmed_at') ->max('confirmed_at'); return $value !== null ? Carbon::parse($value) : null; } /** * ماندهٔ قبلی که آن هم بعد از ثبت آخرین سند گرفته شده باشد. * * @return array|null */ private function previousComparableMande(PargarSyncLog $log, ?Carbon $lastConfirmedAt): ?array { $query = PargarSyncLog::query() ->where('customer_id', $log->customer_id) ->where('type', PargarSyncService::TYPE_MANDE) ->where('id', '<', $log->id) ->orderByDesc('id'); if ($lastConfirmedAt !== null) { $query->where('created_at', '>', $lastConfirmedAt); } $previous = $query->first(); if ($previous === null) { return null; } $data = json_decode((string) $previous->data, true); return is_array($data) ? $data : null; } /** * @param array $current * @param array $previous */ private function mandeValuesMatch(array $current, array $previous): bool { $goldMatch = abs( $this->parseNumber($current['GoldInv'] ?? 0) - $this->parseNumber($previous['GoldInv'] ?? 0) ) < 0.001; $rialMatch = abs( $this->parseNumber($current['RialInv'] ?? 0) - $this->parseNumber($previous['RialInv'] ?? 0) ) < 1; return $goldMatch && $rialMatch; } /** * @param array $data */ private function markMandeSkipped(PargarSyncLog $log, array $data, string $reason): void { if (($data['mande_skip_reason'] ?? null) === $reason) { return; } $data['mande_skip_reason'] = $reason; $data['mande_skipped_at'] = now()->toDateTimeString(); $log->data = json_encode($data, JSON_UNESCAPED_UNICODE); $log->save(); } private function parseNumber(mixed $value): float { if ($value === null || $value === '') { return 0.0; } return (float) str_replace('/', '.', (string) $value); } /** * همگام‌سازی خودکار در پس‌زمینه — بعد از مانده / تأیید سند در پرگار / import. */ public function queueWalletSyncAfterResponse(int $customerId): void { dispatch(function () use ($customerId) { $customer = Customer::find($customerId); if ($customer === null || ! $customer->hasValidPargarAccountId()) { return; } try { $this->syncWalletFromPargar($customer, forceMande: false, auto: true); } catch (\Throwable $e) { Log::warning('Pargar auto wallet sync failed', [ 'customer_id' => $customerId, 'error' => $e->getMessage(), ]); } })->afterResponse(); } /** * لاگ‌های مانده‌ای که قبل از ساخت/لینک مشتری با pargar_ref آمده‌اند را به مشتری وصل می‌کند. */ public function attachOrphanMandeLogs(Customer $customer): int { if (! $customer->hasValidPargarAccountId()) { return 0; } $accountId = trim((string) $customer->pargar_account_id); return PargarSyncLog::query() ->where('type', PargarSyncService::TYPE_MANDE) ->where('pargar_ref', $accountId) ->where(function ($q) { $q->whereNull('customer_id')->orWhere('customer_id', 0); }) ->update(['customer_id' => $customer->id]); } /** * اسناد یتیم (قبل از لینک مشتری) را به customer_id وصل می‌کند. */ public function attachOrphanDocumentLogs(Customer $customer): int { if (! $customer->hasValidPargarAccountId()) { return 0; } $accountId = trim((string) $customer->pargar_account_id); return PargarSyncLog::query() ->where('type', PargarSyncService::TYPE_DOCUMENT) ->where('pargar_ref', $accountId) ->where(function ($q) { $q->whereNull('customer_id')->orWhere('customer_id', 0); }) ->update(['customer_id' => $customer->id]); } }