Files
boursecu_server/app/Services/PargarInboundWalletService.php
T

439 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Models\Customer;
use App\Models\PargarSyncLog;
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 اسناد (/documents) هرگز کیف را عوض نمی‌کند — فقط لاگ.
* 2) دکمه: دسته‌های سند را فقط «مصرف‌شده» علامت می‌زند (بدون دلتا روی کیف)،
* سپس مانده (mande) را به‌عنوان منبع حقیقت روی کیف می‌نشاند.
* 3) اعمال تک‌تک سطرهای تاریخچهٔ پرگار ممنوع است — باعث تورم/تایم‌اوت می‌شود.
*/
class PargarInboundWalletService
{
public const TX_TYPE_PARGAR = 22;
public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled';
/** سقف تعداد دسته برای علامت‌زدن در هر کلیک */
private const MAX_BATCHES_PER_SYNC = 50;
public function __construct(
private readonly WalletBroadcastService $walletBroadcast
) {
}
public static function isApplyFrozen(): bool
{
return (bool) Cache::get(self::KILL_SWITCH_CACHE_KEY, false);
}
public static function freezeApplies(bool $frozen = true): void
{
Cache::forever(self::KILL_SWITCH_CACHE_KEY, $frozen);
}
/**
* دکمهٔ ادمین:
* ۱) علامت مصرف دسته‌های سند (بدون نوشتن روی کیف)
* ۲) هم‌ترازی مانده با آخرین لاگ mande
*
* @return array{
* documents: array{applied: int, skipped: int, batches: int, acknowledged: int, errors: list<string>},
* 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<string>}
*/
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<string>}
*/
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<string, mixed> $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<string, mixed> $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);
}
/**
* پس از اتصال کد پرگار / claim ثبت‌نام — همگام‌سازی کیف در پس‌زمینه.
*/
public function queueWalletSyncAfterResponse(int $customerId): void
{
dispatch(function () use ($customerId) {
$customer = Customer::find($customerId);
if ($customer === null || ! $customer->hasValidPargarAccountId()) {
return;
}
$this->attachOrphanMandeLogs($customer);
try {
$this->syncWalletFromPargar($customer, 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]);
}
}