diff --git a/app/Http/Controllers/Pargar/PargarInboundController.php b/app/Http/Controllers/Pargar/PargarInboundController.php index 52dc8a2..7e3b550 100644 --- a/app/Http/Controllers/Pargar/PargarInboundController.php +++ b/app/Http/Controllers/Pargar/PargarInboundController.php @@ -11,6 +11,7 @@ use App\Models\PargarSyncLog; use App\Services\ConfigService; use App\Services\PargarInboundWalletService; use App\Services\PargarSyncService; +use App\Services\WalletBalanceService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Schema; use Morilog\Jalali\Jalalian; @@ -322,7 +323,9 @@ class PargarInboundController extends Controller $walletInterface = new WalletInterface($customer->id, $ownerType); $wallet = $walletInterface->wallet; - $appGold = (float) ($wallet->gold ?? 0); + // GoldInv پرگار فقط با طلای کیف اصلی مقایسه می‌شود — محصولات جدا سهمی ندارند + $appGold = app(WalletBalanceService::class) + ->effectiveMainWalletGold((int) $customer->id, $ownerType); $appCash = (float) ($wallet->cash ?? 0); $goldDiff = $goldInv - $appGold; $rialDiff = $rialInv - $appCash; @@ -333,7 +336,7 @@ class PargarInboundController extends Controller $desc = $pendingOutbound ? 'مانده دریافت شد — منتظر ثبت سند پنل در پرگار' : ($hasDiff - ? 'اختلاف مانده پرگار با اپ — در صف همگام‌سازی خودکار' + ? 'اختلاف مانده پرگار با اپ — پس از تأیید پایداری اعمال می‌شود' : 'دریافت مانده از پرگار — بدون اختلاف'); PargarSyncLog::create([ diff --git a/app/Models/PargarSyncLog.php b/app/Models/PargarSyncLog.php index e298163..77b80b5 100644 --- a/app/Models/PargarSyncLog.php +++ b/app/Models/PargarSyncLog.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Schema; class PargarSyncLog extends Model { @@ -22,8 +23,29 @@ class PargarSyncLog extends Model 'pargar_ref', 'res', 'data', + 'confirmed_at', ]; + protected $casts = [ + 'confirmed_at' => 'datetime', + ]; + + private static ?bool $hasConfirmedAtColumn = null; + + /** سازگاری با نصب‌هایی که هنوز مایگریشن confirmed_at را اجرا نکرده‌اند */ + public static function hasConfirmedAtColumn(): bool + { + if (self::$hasConfirmedAtColumn === null) { + try { + self::$hasConfirmedAtColumn = Schema::hasColumn('pargar_sync_logs', 'confirmed_at'); + } catch (\Throwable) { + self::$hasConfirmedAtColumn = false; + } + } + + return self::$hasConfirmedAtColumn; + } + public function customer(): BelongsTo { return $this->belongsTo(Customer::class); diff --git a/app/Services/PargarInboundWalletService.php b/app/Services/PargarInboundWalletService.php index 81791d5..d50bb94 100644 --- a/app/Services/PargarInboundWalletService.php +++ b/app/Services/PargarInboundWalletService.php @@ -4,22 +4,24 @@ namespace App\Services; use App\Models\Customer; use App\Models\PargarSyncLog; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Rooyesh\Wallet\interfaces\TransactionInterface; use Rooyesh\Wallet\interfaces\WalletInterface; -use Rooyesh\Wallet\models\Wallet; /** * همگام‌سازی کیف با پرگار. * * قواعد سخت: * 1) webhook اسناد inbound فقط لاگ می‌کند؛ کیف را با replay تاریخچه عوض نمی‌کند. - * 2) مانده برای مشتریانی که سند ارسالیِ pending به پرگار ندارند → خودکار روی کیف ست می‌شود. - * 3) اگر سند پنل هنوز در پرگار ثبت نشده، مانده اعمال نمی‌شود؛ بعد از تأیید ثبت در پرگار، - * ماندهٔ جدید خودکار سینک می‌شود (تا موجودی مشتری بی‌دلیل کم/زیاد نشود). - * 4) دکمهٔ ادمین می‌تواند force کند. - * 5) اعمال تک‌تک سطرهای تاریخچهٔ پرگار ممنوع است. + * 2) دامنهٔ مانده: RialInv = کیف ریالی، GoldInv = طلای کیف اصلی (main_wallet_products). + * محصولات خارج از کیف اصلی هرگز از مانده اثر نمی‌گیرند. + * 3) ماندهٔ کهنه دور ریخته می‌شود، نه معوق: اگر سند ما هنوز pending باشد یا snapshot + * پیش از ثبت سند گرفته شده باشد، اعمال نمی‌شود و منتظر ماندهٔ بعدی می‌مانیم. + * 4) اعمال فقط با دو snapshot پیاپیِ هم‌مقدار — تا مانده‌ای که وسط ثبت اسناد گرفته شده + * موجودی مشتری را عقب نبرد. + * 5) دکمهٔ ادمین با force می‌تواند همهٔ گیت‌های بالا را رد کند (ابزار اصلاح دستی). */ class PargarInboundWalletService { @@ -67,8 +69,8 @@ class PargarInboundWalletService * ۱) علامت مصرف دسته‌های سند inbound (بدون نوشتن روی کیف) * ۲) هم‌ترازی مانده با آخرین لاگ mande * - * @param bool $forceMande ادمین: حتی با سند ارسالی pending - * @param bool $auto مسیر خودکار: سند ارسالی pending محترم بماند + * @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} @@ -113,12 +115,10 @@ class PargarInboundWalletService // ۱) تاریخچهٔ اسناد inbound را روی کیف replay نکن — فقط مصرف‌شده علامت بزن $documents = $this->acknowledgePendingDocumentBatches($customer); - // ۲) منبع حقیقت موجودی = ماندهٔ پرگار - // خودکار: اگر سند پنل هنوز در پرگار ثبت نشده → اعمال نکن (force=false) - // ادمین: force تا بتوان اصلاح کرد + // ۲) منبع حقیقت موجودی = ماندهٔ پرگار، اما فقط ماندهٔ معتبر $mande = $this->applyLatestMandeDifference( $customer, - force: $auto ? false : ($forceMande || true) + force: ! $auto && $forceMande ); if (($documents['acknowledged'] ?? 0) > 0 || ($mande['applied'] ?? false)) { @@ -274,18 +274,16 @@ class PargarInboundWalletService } /** + * ماندهٔ پرگار روی کیف اپ: + * 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 { - // دکمهٔ ادمین (force): حتی با سند pending خروجی، مانده را هم‌تراز کن - if (! $force && $this->hasPendingOutboundToPargar($customer)) { - return [ - 'applied' => false, - 'message' => 'سند پنل هنوز در پرگار ثبت نشده — مانده بعد از ثبت سند اعمال می‌شود', - ]; - } - $log = PargarSyncLog::query() ->where('customer_id', $customer->id) ->where('type', PargarSyncService::TYPE_MANDE) @@ -301,6 +299,15 @@ class PargarInboundWalletService 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); @@ -308,30 +315,10 @@ class PargarInboundWalletService $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); + $mainGold = app(WalletBalanceService::class) + ->effectiveMainWalletGold((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) { @@ -367,12 +354,21 @@ class PargarInboundWalletService $data['applied_gold_delta'] = $goldDiff; $data['applied_rial_delta'] = $rialDiff; $data['applied_at'] = now()->toDateTimeString(); + $data['applied_forced'] = $force; $data['main_gold_before'] = $mainGold; - $data['non_main_product_gold'] = $nonMainProductGold; + 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, @@ -382,23 +378,160 @@ class PargarInboundWalletService } /** - * جمع طلای کیف‌های محصول خارج از main_wallet_products (مثل قلک سرمایه‌گذاری). + * ماندهٔ پرگار فقط وقتی معتبر است که همهٔ اسناد ما در پرگار ثبت شده باشند و + * snapshot بعد از آن ثبت گرفته شده باشد. اعمال ماندهٔ قدیمی یعنی برگرداندن + * موجودی مشتری به قبل از سند — همان «وزن/ریالم کم شد». + * + * @param array $data + * @return array{reason: string, message: string}|null null یعنی معتبر */ - private function sumNonMainProductWalletGold(int $customerId, int $ownerType): float + private function mandeBlockReason(Customer $customer, PargarSyncLog $log, array $data): ?array { - $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); + if ($this->hasPendingOutboundToPargar($customer)) { + return [ + 'reason' => 'pending_outbound', + 'message' => 'سند پنل هنوز در پرگار ثبت نشده — مانده اعمال نمی‌شود', + ]; } - return round((float) $query->sum('gold'), 3); + $lastConfirmedAt = $this->lastOutboundConfirmedAt($customer); + $takenAt = $log->created_at; + + if ($lastConfirmedAt !== null && ($takenAt === null || $takenAt->lte($lastConfirmedAt))) { + return [ + 'reason' => 'taken_before_document', + 'message' => 'این مانده پیش از ثبت آخرین سند گرفته شده — نادیده گرفته شد', + ]; + } + + $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; + } + + /** + * ماندهٔ صفرِ هر دو فیلد برای مشتری‌ای که کیفش خالی نیست، تقریباً همیشه یعنی + * پرگار حساب را پیدا نکرده — نه اینکه مشتری تسویه کرده باشد. + * + * @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 diff --git a/app/Services/PargarSyncService.php b/app/Services/PargarSyncService.php index ca385a9..01e9085 100644 --- a/app/Services/PargarSyncService.php +++ b/app/Services/PargarSyncService.php @@ -51,6 +51,11 @@ class PargarSyncService $update['data'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); } + // لحظهٔ ثبت سند در پرگار: ماندهٔ قدیمی‌تر از این لحظه دیگر معتبر نیست + if (PargarSyncLog::hasConfirmedAtColumn()) { + $update['confirmed_at'] = now(); + } + PargarSyncLog::where('id', $log->id)->update($update); } diff --git a/database/migrations/2026_07_25_000001_add_confirmed_at_to_pargar_sync_logs.php b/database/migrations/2026_07_25_000001_add_confirmed_at_to_pargar_sync_logs.php new file mode 100644 index 0000000..018cf33 --- /dev/null +++ b/database/migrations/2026_07_25_000001_add_confirmed_at_to_pargar_sync_logs.php @@ -0,0 +1,30 @@ +timestamp('confirmed_at')->nullable()->after('sync_status')->index(); + }); + } + + public function down(): void + { + if (! Schema::hasTable('pargar_sync_logs') || ! Schema::hasColumn('pargar_sync_logs', 'confirmed_at')) { + return; + } + + Schema::table('pargar_sync_logs', function (Blueprint $table) { + $table->dropColumn('confirmed_at'); + }); + } +};