blockingOutboundLog($customer) !== null; } /** * سند پنلی که واقعاً منتظر ثبت در پرگار است. * * لاگ pending که دیگر قابل ارسال نیست (سفارش حذف شده یا از حالت «موفق» درآمده) * هیچ‌وقت تأیید نمی‌شود و اگر بشمریمش، مانده برای همیشه قفل می‌ماند؛ پس آن را * failed می‌کنیم تا از صف بیرون برود. */ public function blockingOutboundLog(Customer $customer): ?PargarSyncLog { $logs = PargarSyncLog::query() ->where('customer_id', $customer->id) ->whereIn('type', [ PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD, PargarSyncService::TYPE_ORDER, ]) ->where('sync_status', PargarSyncService::STATUS_PENDING) ->orderBy('id') ->get(); foreach ($logs as $log) { if ($this->isDeliverableOutbound($log)) { return $log; } $this->retireDeadOutbound($log); } return null; } /** * آیا این لاگ در نوبت ارسال به پرگار (GET /pargar/orders) قرار می‌گیرد؟ */ private function isDeliverableOutbound(PargarSyncLog $log): bool { if ($log->type !== PargarSyncService::TYPE_ORDER) { // سند کیف تا وقتی pending است در هر بار fetch پرگار فرستاده می‌شود return true; } $orderId = (int) ($log->order_id ?? 0); if ($orderId <= 0) { return false; } return Order::query() ->where('id', $orderId) ->where('sts', 2) ->whereNull('pargar_confirmed_at') ->exists(); } private function retireDeadOutbound(PargarSyncLog $log): void { app(PargarSyncService::class)->markFailed( $log, null, 'سفارش این سند دیگر در نوبت ارسال به پرگار نیست (حذف یا تغییر وضعیت) — از صف خارج شد' ); Log::warning('Pargar outbound log retired', [ 'log_id' => $log->id, 'customer_id' => $log->customer_id, 'order_id' => $log->order_id, ]); } /** * دکمهٔ ادمین / همگام‌سازی خودکار: * ۱) علامت مصرف دسته‌های سند 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()) { // سند و مانده اغلب پشت‌سرهم می‌آیند؛ اگر قفل اشغال است دوباره صف کن $this->queueWalletSyncAfterResponse((int) $customer->id, delaySeconds: 3); 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' => [], ]; // فقط شناسهٔ دسته‌های علامت‌نخورده — payload دسته‌های تأییدشده می‌تواند مگابایتی باشد // و کشیدن دوبارهٔ آن‌ها در هر کلیک همان کندی/۵۰۴ است. $batchIds = PargarSyncLog::query() ->where('type', PargarSyncService::TYPE_DOCUMENT) ->where('customer_id', $customer->id) ->whereNotNull('data') ->where('data', 'not like', '%wallet_batch_applied%') ->where('data', 'not like', '%"wallet_applied": true%') ->where('data', 'not like', '%"wallet_applied":true%') ->orderBy('id') ->limit(self::MAX_BATCHES_PER_SYNC) ->pluck('id'); foreach ($batchIds as $batchId) { if ($result['batches'] >= self::MAX_BATCHES_PER_SYNC) { break; } $batch = PargarSyncLog::find($batchId); if ($batch === null) { continue; } $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'], $block['message']); 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) ->accountingVisibleMainWalletGold((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; unset($data['mande_skip_reason'], $data['mande_skipped_at']); $log->data = json_encode($data, JSON_UNESCAPED_UNICODE); // اگر قبلاً اعمال شده، شرح «اعمال مانده» را با «بدون اختلاف» عوض نکن if (empty($data['applied_at'])) { $log->desc = 'دریافت مانده از پرگار — بدون اختلاف'; } $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 { $blocking = $this->blockingOutboundLog($customer); if ($blocking !== null) { return [ 'reason' => 'pending_outbound', 'message' => 'سند پنل شمارهٔ ' . $blocking->id . ' (' . $this->outboundLogLabel($blocking) . ') هنوز در پرگار ثبت نشده — مانده اعمال نمی‌شود', ]; } $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) { // ترمیم خودکار: سند جاافتاده را صف کن تا pending بماند و بعد از ثبت در پرگار مانده باز شود $queued = 0; try { $queued = app(PargarSyncService::class)->queueMissingCashDocuments( $customer, $missing['rows'], 'mande_auto_missing' ); } catch (\Throwable $e) { report($e); } $suffix = $queued > 0 ? ' — سند در صف ثبت قرار گرفت؛ تا تأیید پرگار، مانده اعمال نمی‌شود' : ' — تا ثبت سند، مانده اعمال نمی‌شود'; return [ 'reason' => 'unexported_wallet_movement', 'message' => 'حرکت ریالی ' . number_format($missing['amount']) . ' ریال هنوز سندی در پرگار ندارد' . $suffix, ]; } return null; } private function outboundLogLabel(PargarSyncLog $log): string { return match ($log->type) { PargarSyncService::TYPE_ORDER => 'سفارش ' . $log->order_id, PargarSyncService::TYPE_WALLET_GOLD => 'واریز/برداشت طلای کیف', PargarSyncService::TYPE_WALLET_CASH => 'واریز/برداشت ریالی کیف', default => (string) $log->type, }; } /** * واریز/برداشت ریالی که سند پرگارش ساخته نشده است. * * تا وقتی پرگار از این حرکت خبر ندارد، ماندهٔ آن هم شامل این پول نیست؛ اعمال آن * موجودی مشتری را پاک می‌کند. پس مانده را نگه می‌داریم تا سند ساخته و ثبت شود. * * @return array{amount: float, withdraw_ids: list, rows: \Illuminate\Support\Collection}|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', 'note']); 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(), 'rows' => $rows, ]; } /** * از کجا دنبال سند جاافتاده بگردیم: بعد از آخرین سند ثبت‌شده، و اگر نبود از * شروع اتصال همین مشتری به پرگار. تراکنش‌های قبل از اتصال سند ندارند و نباید * سینک را برای همیشه قفل کنند. */ 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; } /** * آخرین لحظه‌ای که پرگار یکی از اسناد ما را ثبت کرده است. */ 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; } /** * @param array $data */ private function markMandeSkipped(PargarSyncLog $log, array $data, string $reason, ?string $message = null): 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); // تا UI روی «در حال اعمال» گیر نکند if ($message !== null && $message !== '') { $log->desc = mb_substr($message, 0, 250); } elseif (is_string($log->desc) && str_contains($log->desc, 'در حال اعمال')) { $log->desc = mb_substr('مانده نگه‌داشته شد — '.$reason, 0, 250); } $log->save(); } private function parseNumber(mixed $value): float { if ($value === null || $value === '') { return 0.0; } return (float) str_replace('/', '.', (string) $value); } /** * همگام‌سازی خودکار در پس‌زمینه — بعد از مانده / تأیید سند در پرگار / import. * * @param int $delaySeconds تأخیر داخل afterResponse (برای رد شدن از قفل webhook قبلی) */ public function queueWalletSyncAfterResponse(int $customerId, int $delaySeconds = 0): void { dispatch(function () use ($customerId, $delaySeconds) { if ($delaySeconds > 0) { sleep(min($delaySeconds, 10)); } $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]); } }