From 80a2eb207a7f3d1410e65094939e4ebd39a375a6 Mon Sep 17 00:00:00 2001 From: adonis Date: Tue, 21 Jul 2026 21:38:48 +0330 Subject: [PATCH] fix some bugs 3 --- .../Controllers/Admin/PargarController.php | 20 +- app/Services/PargarInboundWalletService.php | 298 ++++-------------- 2 files changed, 80 insertions(+), 238 deletions(-) diff --git a/app/Http/Controllers/Admin/PargarController.php b/app/Http/Controllers/Admin/PargarController.php index 58bf8e9..0ee7c69 100644 --- a/app/Http/Controllers/Admin/PargarController.php +++ b/app/Http/Controllers/Admin/PargarController.php @@ -274,20 +274,22 @@ class PargarController extends Controller $force = $request->boolean('force'); $result = app(PargarInboundWalletService::class)->syncWalletFromPargar($customer, $force); - $appliedDocs = (int) ($result['documents']['applied'] ?? 0); + $acknowledged = (int) ($result['documents']['acknowledged'] ?? 0); $skippedDocs = (int) ($result['documents']['skipped'] ?? 0); $batches = (int) ($result['documents']['batches'] ?? 0); $appliedMande = (bool) ($result['mande']['applied'] ?? false); $mandeMessage = (string) ($result['mande']['message'] ?? ''); - $ok = $appliedDocs > 0 || $appliedMande; + $docErrors = $result['documents']['errors'] ?? []; + $ok = $appliedMande || $acknowledged > 0; - if ($appliedDocs > 0 && $appliedMande) { - $message = "{$appliedDocs} سند اعمال شد و مانده هم‌تراز شد"; - } elseif ($appliedDocs > 0) { - $message = "{$appliedDocs} سند اعمال شد" - .($mandeMessage !== '' ? " — {$mandeMessage}" : ''); + if ($appliedMande && $acknowledged > 0) { + $message = "مانده هم‌تراز شد ({$acknowledged} دسته سند مصرف شد)"; } elseif ($appliedMande) { $message = 'مانده با پرگار هم‌تراز شد'; + } elseif ($acknowledged > 0) { + $message = $mandeMessage !== '' + ? "{$acknowledged} دسته سند مصرف شد — {$mandeMessage}" + : "{$acknowledged} دسته سند مصرف شد"; } elseif ($batches > 0 && $skippedDocs > 0) { $message = $mandeMessage !== '' ? $mandeMessage @@ -298,6 +300,10 @@ class PargarController extends Controller : 'سند یا ماندهٔ جدیدی برای اعمال نبود'; } + if (is_array($docErrors) && $docErrors !== []) { + $message .= ' — '.implode('؛ ', array_slice($docErrors, 0, 2)); + } + return response()->json([ 'ok' => $ok, 'message' => $message, diff --git a/app/Services/PargarInboundWalletService.php b/app/Services/PargarInboundWalletService.php index cdbe3b2..1ad91d5 100644 --- a/app/Services/PargarInboundWalletService.php +++ b/app/Services/PargarInboundWalletService.php @@ -14,9 +14,9 @@ use Rooyesh\Wallet\interfaces\WalletInterface; * * قواعد سخت: * 1) webhook اسناد (/documents) هرگز کیف را عوض نمی‌کند — فقط لاگ. - * 2) دکمه: اول اسنادِ اعمال‌نشدهٔ همان مشتری، بعد مانده. - * 3) هر سطر سند حداکثر یک‌بار (اثرانگشت). - * 4) هر لاگ دسته حداکثر یک‌بار پردازش می‌شود (wallet_batch_applied). + * 2) دکمه: دسته‌های سند را فقط «مصرف‌شده» علامت می‌زند (بدون دلتا روی کیف)، + * سپس مانده (mande) را به‌عنوان منبع حقیقت روی کیف می‌نشاند. + * 3) اعمال تک‌تک سطرهای تاریخچهٔ پرگار ممنوع است — باعث تورم/تایم‌اوت می‌شود. */ class PargarInboundWalletService { @@ -24,11 +24,8 @@ class PargarInboundWalletService public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled'; - /** سقف تعداد دستهٔ سند در هر کلیک (جلوگیری از timeout) */ - private const MAX_BATCHES_PER_SYNC = 30; - - /** @var array */ - private array $seenFingerprints = []; + /** سقف تعداد دسته برای علامت‌زدن در هر کلیک */ + private const MAX_BATCHES_PER_SYNC = 50; public function __construct( private readonly WalletBroadcastService $walletBroadcast @@ -47,11 +44,11 @@ class PargarInboundWalletService /** * دکمهٔ ادمین: - * ۱) اسناد لاگ‌شدهٔ اعمال‌نشده برای همین مشتری - * ۲) سپس هم‌ترازی مانده (mande) + * ۱) علامت مصرف دسته‌های سند (بدون نوشتن روی کیف) + * ۲) هم‌ترازی مانده با آخرین لاگ mande * * @return array{ - * documents: array{applied: int, skipped: int, batches: int, errors: list}, + * documents: array{applied: int, skipped: int, batches: int, acknowledged: int, errors: list}, * mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} * } */ @@ -61,7 +58,13 @@ class PargarInboundWalletService if (self::isApplyFrozen()) { return [ - 'documents' => ['applied' => 0, 'skipped' => 0, 'batches' => 0, 'errors' => []], + 'documents' => [ + 'applied' => 0, + 'skipped' => 0, + 'batches' => 0, + 'acknowledged' => 0, + 'errors' => [], + ], 'mande' => [ 'applied' => false, 'message' => 'اعمال کیف یخ‌زده است — php artisan pargar:wallet-freeze off', @@ -69,23 +72,28 @@ class PargarInboundWalletService ]; } - $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 120); + $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 60); if (! $lock->get()) { return [ - 'documents' => ['applied' => 0, 'skipped' => 0, 'batches' => 0, 'errors' => ['همگام‌سازی در حال اجراست']], + 'documents' => [ + 'applied' => 0, + 'skipped' => 0, + 'batches' => 0, + 'acknowledged' => 0, + 'errors' => ['همگام‌سازی در حال اجراست'], + ], 'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست'], ]; } try { - // ۱) اسناد لاگ‌شده که هنوز روی کیف نرفته‌اند - $documents = $this->applyPendingDocumentBatches($customer); + // ۱) تاریخچهٔ اسناد را روی کیف replay نکن — فقط مصرف‌شده علامت بزن + $documents = $this->acknowledgePendingDocumentBatches($customer); - // ۲) همیشه مانده را با آخرین لاگ mande هم‌تراز کن (حتی اگر سندی نبود) - // force تا بعد از اعمال اسناد، موجودی متورم به ماندهٔ واقعی پرگار برگردد + // ۲) منبع حقیقت موجودی = ماندهٔ پرگار $mande = $this->applyLatestMandeDifference($customer, force: true); - if ($documents['applied'] > 0 || ($mande['applied'] ?? false)) { + if (($documents['acknowledged'] ?? 0) > 0 || ($mande['applied'] ?? false)) { $this->walletBroadcast->broadcastForCustomer( (int) $customer->id, (int) ($customer->type ?? 2) @@ -102,16 +110,18 @@ class PargarInboundWalletService } /** - * فقط لاگ‌های دستهٔ ورودی که هنوز روی کیف اعمال نشده‌اند. + * دسته‌های ورودی پرگار را بدون تغییر کیف، مصرف‌شده علامت می‌زند. + * (اعمال تک‌تک bed/bes تاریخچه = تورم کیف و 504) * - * @return array{applied: int, skipped: int, batches: int, errors: list} + * @return array{applied: int, skipped: int, batches: int, acknowledged: int, errors: list} */ - public function applyPendingDocumentBatches(Customer $customer): array + public function acknowledgePendingDocumentBatches(Customer $customer): array { $result = [ 'applied' => 0, 'skipped' => 0, 'batches' => 0, + 'acknowledged' => 0, 'errors' => [], ]; @@ -132,7 +142,6 @@ class PargarInboundWalletService continue; } - // فقط دسته‌های ورودی پرگار (آرایه transactions) — نه رکورد تک‌سند اعمال‌شده if (empty($data['transactions']) || ! is_array($data['transactions'])) { continue; } @@ -142,30 +151,17 @@ class PargarInboundWalletService } $result['batches']++; - $accountId = trim((string) ($data['accountId'] ?? $customer->pargar_account_id ?? '')); + $txCount = count($data['transactions']); + $result['skipped'] += $txCount; - foreach ($data['transactions'] as $tx) { - if (! is_array($tx)) { - continue; - } - - $outcome = $this->applyOneTransaction($customer, $accountId, $tx); - if ($outcome === 'applied') { - $result['applied']++; - } elseif ($outcome === 'skipped') { - $result['skipped']++; - } else { - $result['errors'][] = $outcome; - } - } - - // حتی اگر همه skip شدند، دسته را مصرف‌شده علامت بزن تا دوباره نیاید - // خطای ذخیره نباید کل sync را بشکند (mande باید بعداً اجرا شود) 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 markDocumentBatchApplied failed', [ + Log::error('Pargar acknowledgeDocumentBatch failed', [ 'log_id' => $batch->id, 'customer_id' => $customer->id, 'error' => $e->getMessage(), @@ -177,7 +173,24 @@ class PargarInboundWalletService } /** - * علامت مصرف دسته — بدون Pretty-Print؛ در صورت بزرگ بودن payload فشرده‌سازی می‌کند. + * @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 */ @@ -190,6 +203,14 @@ class PargarInboundWalletService $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); @@ -200,7 +221,6 @@ class PargarInboundWalletService try { $batch->save(); } catch (\Throwable $e) { - // ستون هنوز TEXT باشد یا payload خیلی بزرگ — stub کوچک تا دسته دوباره نیاید $batch->data = $this->encodeAppliedBatchCompact($data); $batch->save(); } @@ -220,61 +240,18 @@ class PargarInboundWalletService '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); } - /** - * @param array $tx - * @return 'applied'|'skipped'|string error - */ - private function applyOneTransaction(Customer $customer, string $accountId, array $tx): string - { - $fingerprint = $this->transactionFingerprint($accountId, $tx); - if ($fingerprint === '' || $this->isFingerprintUsed($fingerprint)) { - return 'skipped'; - } - - $this->seenFingerprints[$fingerprint] = true; - - $goldDelta = $this->parseNumber($tx['besGold'] ?? 0) - $this->parseNumber($tx['bedGold'] ?? 0); - $rialDelta = $this->parseNumber($tx['besRial'] ?? 0) - $this->parseNumber($tx['bedRial'] ?? 0); - - if (abs($goldDelta) < 0.01 && abs($rialDelta) < 1) { - $this->rememberFingerprint($customer, $fingerprint, $accountId, $tx, false, 'zero'); - - return 'skipped'; - } - - if ($this->isEchoOfRecentOutbound((int) $customer->id, $goldDelta, $rialDelta)) { - $this->rememberFingerprint($customer, $fingerprint, $accountId, $tx, false, 'echo'); - - return 'skipped'; - } - - try { - $this->writeWalletDelta($customer, $tx, $goldDelta, $rialDelta); - $this->rememberFingerprint($customer, $fingerprint, $accountId, $tx, true, null, $goldDelta, $rialDelta); - - return 'applied'; - } catch (\Throwable $e) { - unset($this->seenFingerprints[$fingerprint]); - Log::warning('Pargar applyOneTransaction failed', [ - 'customer_id' => $customer->id, - 'fingerprint' => $fingerprint, - 'error' => $e->getMessage(), - ]); - - return $e->getMessage(); - } - } - /** * @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)) { + // دکمهٔ ادمین (force): حتی با سند pending خروجی، مانده را هم‌تراز کن تا کیف متورم اصلاح شود + if (! $force && $this->hasPendingOutboundWalletSync((int) $customer->id)) { return [ 'applied' => false, 'message' => 'سند ارسالی از پنل هنوز تأیید نشده — مانده اعمال نشد تا شارژ برنگردد', @@ -351,142 +328,6 @@ class PargarInboundWalletService ]; } - /** - * @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)); - } - - private function isFingerprintUsed(string $fingerprint): bool - { - if ($fingerprint === '' || isset($this->seenFingerprints[$fingerprint])) { - return true; - } - - return PargarSyncLog::query() - ->where('type', PargarSyncService::TYPE_DOCUMENT) - ->where('pargar_ref', $fingerprint) - ->exists(); - } - - /** - * @param array $tx - */ - private function rememberFingerprint( - Customer $customer, - string $fingerprint, - string $accountId, - array $tx, - bool $applied, - ?string $skipReason, - float $goldDelta = 0, - float $rialDelta = 0 - ): void { - if (PargarSyncLog::query() - ->where('type', PargarSyncService::TYPE_DOCUMENT) - ->where('pargar_ref', $fingerprint) - ->exists()) { - return; - } - - PargarSyncLog::create([ - 'customer_id' => $customer->id, - 'amount' => abs($rialDelta) >= 1 ? abs($rialDelta) : abs($goldDelta), - 'desc' => $applied - ? 'اعمال سند پرگار' - : ('رد سند پرگار'.($skipReason ? " ({$skipReason})" : '')), - 'type' => PargarSyncService::TYPE_DOCUMENT, - 'sync_status' => PargarSyncService::STATUS_SUCCESS, - 'pargar_ref' => $fingerprint, - 'data' => json_encode([ - 'accountId' => $accountId, - 'transaction' => $tx, - 'wallet_applied' => $applied, - 'fingerprint' => $fingerprint, - 'skip_reason' => $skipReason, - 'gold_delta' => $goldDelta, - 'rial_delta' => $rialDelta, - ], JSON_UNESCAPED_UNICODE), - ]); - } - - /** - * @param array $tx - */ - private function writeWalletDelta(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' => mb_substr($title, 0, 180), - '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 - ); - } - - private function isEchoOfRecentOutbound(int $customerId, float $goldDelta, float $rialDelta): bool - { - $logs = PargarSyncLog::query() - ->where('customer_id', $customerId) - ->whereIn('type', [PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD]) - ->where('created_at', '>=', now()->subDays(3)) - ->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() @@ -496,11 +337,6 @@ class PargarInboundWalletService ->exists(); } - private function normalizeNumberString(mixed $value): string - { - return (string) $this->parseNumber($value); - } - private function parseNumber(mixed $value): float { if ($value === null || $value === '') {