}, * 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, '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 { // ۱) تاریخچهٔ اسناد را روی کیف replay نکن — فقط مصرف‌شده علامت بزن $documents = $this->acknowledgePendingDocumentBatches($customer); // ۲) منبع حقیقت موجودی = ماندهٔ پرگار $mande = $this->applyLatestMandeDifference($customer, force: true); 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); } /** * @return array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} */ public function applyLatestMandeDifference(Customer $customer, bool $force = false): array { // دکمهٔ ادمین (force): حتی با سند pending خروجی، مانده را هم‌تراز کن تا کیف متورم اصلاح شود if (! $force && $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; $balanceService = app(WalletBalanceService::class); $mainGold = $balanceService->effectiveMainWalletGold((int) $customer->id, $ownerType); $nonMainProductGold = $this->sumNonMainProductWalletGold((int) $customer->id, $ownerType); // GoldInv پرگار = آبشدهٔ کیف اصلی. طلای محصولات جدا (مثل قلک سرمایه‌گذاری) نباید کپی شود. $goldDiff = $goldInv - $mainGold; // تکرار شناخته‌شده: مانده ≈ طلای محصول جدا و کیف اصلی هم همان مقدار را دارد if ( $nonMainProductGold >= 0.01 && abs($goldInv - $nonMainProductGold) < 0.05 && abs($mainGold - $nonMainProductGold) < 0.05 ) { $goldDiff = -$mainGold; } elseif ( $goldDiff > 0.01 && $nonMainProductGold >= 0.01 && abs($goldInv - $nonMainProductGold) < 0.05 && $mainGold < 0.01 ) { // GoldInv فقط همان موجودی محصول است — روی کیف اصلی نریز $goldDiff = 0.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); $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(); $data['main_gold_before'] = $mainGold; $data['non_main_product_gold'] = $nonMainProductGold; $log->data = json_encode($data, JSON_UNESCAPED_UNICODE); $log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ'; $log->save(); return [ 'applied' => true, 'gold_delta' => $goldDiff, 'rial_delta' => $rialDiff, 'message' => 'مانده با پرگار هم‌تراز شد', ]; } /** * جمع طلای کیف‌های محصول خارج از main_wallet_products (مثل قلک سرمایه‌گذاری). */ private function sumNonMainProductWalletGold(int $customerId, int $ownerType): float { $mainWalletProductIds = app(ConfigService::class)->mainWalletProductIds(); $query = Wallet::query() ->where('owner', $customerId) ->where('owner_type', $ownerType) ->whereNull('fund_id') ->whereNotNull('product_id'); if ($mainWalletProductIds !== []) { $query->whereNotIn('product_id', $mainWalletProductIds); } return round((float) $query->sum('gold'), 3); } 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 parseNumber(mixed $value): float { if ($value === null || $value === '') { return 0.0; } return (float) str_replace('/', '.', (string) $value); } }