fix pargar bugs 7

This commit is contained in:
2026-07-21 14:33:16 +03:30
parent 64a4a4ca95
commit 468fb95a57
2 changed files with 315 additions and 60 deletions
@@ -275,20 +275,27 @@ class PargarController extends Controller
$force = $request->boolean('force'); $force = $request->boolean('force');
$result = app(PargarInboundWalletService::class)->syncWalletFromPargar($customer, $force); $result = app(PargarInboundWalletService::class)->syncWalletFromPargar($customer, $force);
$appliedDocs = (int) ($result['documents']['applied'] ?? 0); $appliedDocs = (int) ($result['documents']['applied'] ?? 0);
$skippedDocs = (int) ($result['documents']['skipped'] ?? 0);
$batches = (int) ($result['documents']['batches'] ?? 0);
$appliedMande = (bool) ($result['mande']['applied'] ?? false); $appliedMande = (bool) ($result['mande']['applied'] ?? false);
$mandeMessage = (string) ($result['mande']['message'] ?? ''); $mandeMessage = (string) ($result['mande']['message'] ?? '');
$ok = $appliedDocs > 0 || $appliedMande; $ok = $appliedDocs > 0 || $appliedMande;
if ($appliedDocs > 0 && $appliedMande) { if ($appliedDocs > 0 && $appliedMande) {
$message = "{$appliedDocs} سند و ماندهٔ پرگار روی کیف اعمال شد"; $message = "{$appliedDocs} سند اعمال شد و مانده هم‌تراز شد";
} elseif ($appliedDocs > 0) { } elseif ($appliedDocs > 0) {
$message = "{$appliedDocs} سند از پرگار روی کیف اعمال شد"; $message = "{$appliedDocs} سند اعمال شد"
.($mandeMessage !== '' ? " — {$mandeMessage}" : '');
} elseif ($appliedMande) { } elseif ($appliedMande) {
$message = 'ماندهٔ پرگار روی کیف اعمال شد'; $message = 'مانده با پرگار هم‌تراز شد';
} elseif ($batches > 0 && $skippedDocs > 0) {
$message = $mandeMessage !== ''
? $mandeMessage
: 'اسناد تکراری بودند؛ تغییری لازم نبود';
} else { } else {
$message = $mandeMessage !== '' $message = $mandeMessage !== ''
? $mandeMessage ? $mandeMessage
: 'سند قابل‌اعمالی یافت نشد'; : 'سند یا ماندهٔ جدیدی برای اعمال نبود';
} }
return response()->json([ return response()->json([
+304 -56
View File
@@ -10,33 +10,34 @@ use Rooyesh\Wallet\interfaces\TransactionInterface;
use Rooyesh\Wallet\interfaces\WalletInterface; use Rooyesh\Wallet\interfaces\WalletInterface;
/** /**
* اعمال اسناد/ماندهٔ ورودی پرگار روی کیف اپ. * همگام‌سازی کیف با پرگار — فقط با دکمهٔ ادمین.
* *
* اضطراری: اعمال اسناد روی کیف به‌طور کامل خاموش است تا از شارژ چندباره جلوگیری شود. * قواعد سخت:
* دکمهٔ ادمین فقط مانده را (در صورت فعال بودن) یک‌بار تنظیم می‌کند. * 1) webhook اسناد (/documents) هرگز کیف را عوض نمی‌کند — فقط لاگ.
* 2) دکمه: اول اسنادِ اعمال‌نشدهٔ همان مشتری، بعد مانده.
* 3) هر سطر سند حداکثر یک‌بار (اثرانگشت).
* 4) هر لاگ دسته حداکثر یک‌بار پردازش می‌شود (wallet_batch_applied).
*/ */
class PargarInboundWalletService class PargarInboundWalletService
{ {
public const TX_TYPE_PARGAR = 22; public const TX_TYPE_PARGAR = 22;
/** کلید کش برای قطع اضطراری هرگونه اعمال کیف از پرگار */
public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled'; public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled';
/** سقف تعداد دستهٔ سند در هر کلیک (جلوگیری از timeout) */
private const MAX_BATCHES_PER_SYNC = 30;
/** @var array<string, true> */
private array $seenFingerprints = [];
public function __construct( public function __construct(
private readonly PargarSyncService $sync,
private readonly WalletBroadcastService $walletBroadcast private readonly WalletBroadcastService $walletBroadcast
) { ) {
} }
public static function isApplyFrozen(): bool public static function isApplyFrozen(): bool
{ {
// پیش‌فرض: یخ‌زده — مگر صریحاً false شده باشد return (bool) Cache::get(self::KILL_SWITCH_CACHE_KEY, false);
$cached = Cache::get(self::KILL_SWITCH_CACHE_KEY);
if ($cached === null) {
return true;
}
return (bool) $cached;
} }
public static function freezeApplies(bool $frozen = true): void public static function freezeApplies(bool $frozen = true): void
@@ -45,78 +46,186 @@ class PargarInboundWalletService
} }
/** /**
* @param array<string, mixed> $data * دکمهٔ ادمین:
* @return array{applied: int, skipped: int, errors: list<string>, logs: list<int>} * ۱) اسناد لاگ‌شدهٔ اعمال‌نشده برای همین مشتری
*/ * ۲) سپس هم‌ترازی مانده (mande)
public function applyDocuments(Customer $customer, array $data, bool $broadcast = true): array *
{
$transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : [];
Log::warning('Pargar applyDocuments blocked (frozen)', [
'customer_id' => $customer->id,
'tx_count' => count($transactions),
]);
return [
'applied' => 0,
'skipped' => count($transactions),
'errors' => ['اعمال اسناد پرگار روی کیف موقتاً غیرفعال است'],
'logs' => [],
];
}
/**
* @return array{ * @return array{
* documents: array{applied: int, skipped: int, errors: list<string>, logs: list<int>}, * documents: array{applied: int, skipped: int, batches: int, errors: list<string>},
* mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} * mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string}
* } * }
*/ */
public function syncWalletFromPargar(Customer $customer, bool $forceMande = false): array public function syncWalletFromPargar(Customer $customer, bool $forceMande = false): array
{ {
if (self::isApplyFrozen() && ! $forceMande) { unset($forceMande); // مانده در دکمه همیشه force می‌شود
if (self::isApplyFrozen()) {
return [ return [
'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []], 'documents' => ['applied' => 0, 'skipped' => 0, 'batches' => 0, 'errors' => []],
'mande' => [ 'mande' => [
'applied' => false, 'applied' => false,
'message' => 'اعمال کیف از پرگار یخ‌زده است — ابتدا با دستور pargar:wallet-freeze off آزاد کنید', 'message' => 'اعمال کیف یخ‌زده است — php artisan pargar:wallet-freeze off',
], ],
]; ];
} }
$lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 90); $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 120);
if (! $lock->get()) { if (! $lock->get()) {
return [ return [
'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => ['همگام‌سازی در حال اجراست'], 'logs' => []], 'documents' => ['applied' => 0, 'skipped' => 0, 'batches' => 0, 'errors' => ['همگام‌سازی در حال اجراست']],
'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست — لطفاً صبر کنید'], 'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست'],
]; ];
} }
try { try {
// ۱) اسناد لاگ‌شده که هنوز روی کیف نرفته‌اند
$documents = $this->applyPendingDocumentBatches($customer);
// ۲) همیشه مانده را با آخرین لاگ mande هم‌تراز کن (حتی اگر سندی نبود)
// force تا بعد از اعمال اسناد، موجودی متورم به ماندهٔ واقعی پرگار برگردد
$mande = $this->applyLatestMandeDifference($customer, force: true);
if ($documents['applied'] > 0 || ($mande['applied'] ?? false)) {
$this->walletBroadcast->broadcastForCustomer(
(int) $customer->id,
(int) ($customer->type ?? 2)
);
}
return [ return [
'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []], 'documents' => $documents,
'mande' => $this->applyLatestMandeDifference($customer, force: $forceMande), 'mande' => $mande,
]; ];
} finally { } finally {
$lock->release(); $lock->release();
} }
} }
/**
* فقط لاگ‌های دستهٔ ورودی که هنوز روی کیف اعمال نشده‌اند.
*
* @return array{applied: int, skipped: int, batches: int, errors: list<string>}
*/
public function applyPendingDocumentBatches(Customer $customer): array
{
$result = [
'applied' => 0,
'skipped' => 0,
'batches' => 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;
}
// فقط دسته‌های ورودی پرگار (آرایه transactions) — نه رکورد تک‌سند اعمال‌شده
if (empty($data['transactions']) || ! is_array($data['transactions'])) {
continue;
}
if (! empty($data['wallet_batch_applied']) || ! empty($data['wallet_applied'])) {
continue;
}
$result['batches']++;
$accountId = trim((string) ($data['accountId'] ?? $customer->pargar_account_id ?? ''));
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 شدند، دسته را مصرف‌شده علامت بزن تا دوباره نیاید
$data['wallet_batch_applied'] = true;
$data['wallet_batch_applied_at'] = now()->toDateTimeString();
$batch->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if (is_string($batch->desc) && ! str_contains($batch->desc, '[اعمال‌شده]')) {
$batch->desc = '[اعمال‌شده] '.$batch->desc;
}
$batch->save();
}
return $result;
}
/**
* @param array<string, mixed> $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} * @return array{applied: bool, gold_delta?: float, rial_delta?: float, message: string}
*/ */
public function applyLatestMandeDifference(Customer $customer, bool $force = false): array public function applyLatestMandeDifference(Customer $customer, bool $force = false): array
{ {
if (self::isApplyFrozen() && ! $force) {
return [
'applied' => false,
'message' => 'اعمال کیف از پرگار یخ‌زده است',
];
}
if ($this->hasPendingOutboundWalletSync((int) $customer->id)) { if ($this->hasPendingOutboundWalletSync((int) $customer->id)) {
return [ return [
'applied' => false, 'applied' => false,
'message' => 'سند ارسالی از پنل به پرگار هنوز تأیید نشده — برای جلوگیری از برگشت شارژ، ماندهٔ پرگار اعمال نشد', 'message' => 'سند ارسالی از پنل هنوز تأیید نشده — مانده اعمال نشد تا شارژ برنگردد',
]; ];
} }
@@ -127,12 +236,12 @@ class PargarInboundWalletService
->first(); ->first();
if ($log === null) { if ($log === null) {
return ['applied' => false, 'message' => 'لاگ ماندهٔ پرگار برای این مشتری یافت نشد']; return ['applied' => false, 'message' => 'لاگ ماندهٔ پرگار برای این مشتری نیست'];
} }
$data = json_decode((string) $log->data, true) ?: []; $data = json_decode((string) $log->data, true) ?: [];
if (! $force && ! empty($data['wallet_applied_from_mande'])) { if (! $force && ! empty($data['wallet_applied_from_mande'])) {
return ['applied' => false, 'message' => 'ماندهٔ پرگار قبلاً روی کیف اعمال شده است']; return ['applied' => false, 'message' => 'مانده قبلاً هم‌تراز شده است'];
} }
$goldInv = $this->parseNumber($data['GoldInv'] ?? 0); $goldInv = $this->parseNumber($data['GoldInv'] ?? 0);
@@ -150,7 +259,7 @@ class PargarInboundWalletService
$log->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); $log->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$log->save(); $log->save();
return ['applied' => false, 'message' => 'اختلافی بین ماندهٔ پرگار و کیف اپ نیست']; return ['applied' => false, 'message' => 'مانده با پرگار یکسان است'];
} }
$input = [ $input = [
@@ -182,16 +291,150 @@ class PargarInboundWalletService
$log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ'; $log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ';
$log->save(); $log->save();
$this->walletBroadcast->broadcastForCustomer((int) $customer->id, $ownerType);
return [ return [
'applied' => true, 'applied' => true,
'gold_delta' => $goldDiff, 'gold_delta' => $goldDiff,
'rial_delta' => $rialDiff, 'rial_delta' => $rialDiff,
'message' => 'ماندهٔ پرگار روی کیف اعمال شد', 'message' => 'مانده با پرگار هم‌تراز شد',
]; ];
} }
/**
* @param array<string, mixed> $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<string, mixed> $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 | JSON_PRETTY_PRINT),
]);
}
/**
* @param array<string, mixed> $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 private function hasPendingOutboundWalletSync(int $customerId): bool
{ {
return PargarSyncLog::query() return PargarSyncLog::query()
@@ -201,6 +444,11 @@ class PargarInboundWalletService
->exists(); ->exists();
} }
private function normalizeNumberString(mixed $value): string
{
return (string) $this->parseNumber($value);
}
private function parseNumber(mixed $value): float private function parseNumber(mixed $value): float
{ {
if ($value === null || $value === '') { if ($value === null || $value === '') {