Files
boursecu_server/app/Services/PargarInboundWalletService.php
T

439 lines
16 KiB
PHP
Raw Normal View History

2026-07-21 12:36:59 +03:30
<?php
namespace App\Services;
use App\Models\Customer;
use App\Models\PargarSyncLog;
2026-07-21 14:13:26 +03:30
use Illuminate\Support\Facades\Cache;
2026-07-21 12:36:59 +03:30
use Illuminate\Support\Facades\Log;
use Rooyesh\Wallet\interfaces\TransactionInterface;
use Rooyesh\Wallet\interfaces\WalletInterface;
/**
2026-07-21 14:13:26 +03:30
* اعمال اسناد/ماندهٔ ورودی پرگار روی کیف اپ.
*
* دکمهٔ ادمین فقط مانده (mande) را یک‌بار تنظیم می‌کند.
* اسناد جدید از webhook با اثرانگشت یکتا اعمال می‌شوند — بدون replay انبوه لاگ‌های قدیمی.
2026-07-21 12:36:59 +03:30
*/
class PargarInboundWalletService
{
public const TX_TYPE_PARGAR = 22;
2026-07-21 14:13:26 +03:30
/** @var array<string, true> */
private array $seenFingerprints = [];
2026-07-21 12:36:59 +03:30
public function __construct(
private readonly PargarSyncService $sync,
private readonly WalletBroadcastService $walletBroadcast
) {
}
/**
* @param array<string, mixed> $data
* @return array{applied: int, skipped: int, errors: list<string>, logs: list<int>}
*/
2026-07-21 14:13:26 +03:30
public function applyDocuments(Customer $customer, array $data, bool $broadcast = true): array
2026-07-21 12:36:59 +03:30
{
$transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : [];
$accountId = trim((string) ($data['accountId'] ?? ''));
$result = [
'applied' => 0,
'skipped' => 0,
'errors' => [],
'logs' => [],
];
if ($transactions === []) {
return $result;
}
2026-07-21 14:13:26 +03:30
$lock = Cache::lock('pargar-wallet-apply:'.$customer->id, 60);
if (! $lock->get()) {
$result['errors'][] = 'همگام‌سازی دیگری برای این مشتری در حال اجراست';
2026-07-21 12:36:59 +03:30
2026-07-21 14:13:26 +03:30
return $result;
2026-07-21 12:36:59 +03:30
}
2026-07-21 14:13:26 +03:30
try {
$walletApplied = false;
foreach ($transactions as $tx) {
if (! is_array($tx)) {
continue;
}
$fingerprint = $this->transactionFingerprint($accountId, $tx);
if ($fingerprint === '' || $this->isFingerprintApplied($fingerprint)) {
$result['skipped']++;
continue;
}
// علامت در حافظه تا در همین درخواست دوباره نخورد
$this->seenFingerprints[$fingerprint] = true;
$goldDelta = $this->transactionGoldDelta($tx);
$rialDelta = $this->transactionRialDelta($tx);
if (abs($goldDelta) < 0.01 && abs($rialDelta) < 1) {
$this->recordProcessedFingerprint($customer, $fingerprint, $accountId, $tx, false, 'zero_amount');
$result['skipped']++;
continue;
}
if ($this->isEchoOfRecentOutbound((int) $customer->id, $goldDelta, $rialDelta)) {
$this->recordProcessedFingerprint($customer, $fingerprint, $accountId, $tx, false, 'echo_outbound');
$result['skipped']++;
continue;
}
try {
$this->applyTransactionDelta($customer, $tx, $goldDelta, $rialDelta);
$walletApplied = true;
$summary = $this->sync->summarizeInboundDocument([
'accountId' => $accountId,
'accountName' => $data['accountName'] ?? null,
'transactions' => [$tx],
]);
$log = PargarSyncLog::create([
'customer_id' => $customer->id,
'amount' => $summary['amount'],
'desc' => $summary['desc'] !== '' ? $summary['desc'] : 'اعمال سند پرگار',
'type' => PargarSyncService::TYPE_DOCUMENT,
'sync_status' => PargarSyncService::STATUS_SUCCESS,
'pargar_ref' => $fingerprint,
'data' => json_encode([
'accountId' => $accountId,
'accountName' => $data['accountName'] ?? null,
'transaction' => $tx,
'wallet_applied' => true,
'fingerprint' => $fingerprint,
'gold_delta' => $goldDelta,
'rial_delta' => $rialDelta,
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
]);
$result['applied']++;
$result['logs'][] = (int) $log->id;
} catch (\Throwable $e) {
unset($this->seenFingerprints[$fingerprint]);
Log::warning('Pargar inbound document apply failed', [
'customer_id' => $customer->id,
'fingerprint' => $fingerprint,
'error' => $e->getMessage(),
]);
$result['errors'][] = 'سند: '.$e->getMessage();
}
}
if ($walletApplied && $broadcast) {
$this->walletBroadcast->broadcastForCustomer(
(int) $customer->id,
(int) ($customer->type ?? 2)
);
}
} finally {
$lock->release();
2026-07-21 12:36:59 +03:30
}
return $result;
}
2026-07-21 12:49:08 +03:30
/**
2026-07-21 14:13:26 +03:30
* دکمهٔ ادمین: فقط اختلاف ماندهٔ پرگار را یک‌بار اعمال می‌کند (نه replay اسناد).
2026-07-21 12:49:08 +03:30
*
2026-07-21 14:13:26 +03:30
* @return array{
* documents: array{applied: int, skipped: int, errors: list<string>, logs: list<int>},
* mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string}
* }
2026-07-21 12:49:08 +03:30
*/
2026-07-21 14:13:26 +03:30
public function syncWalletFromPargar(Customer $customer, bool $forceMande = false): array
2026-07-21 12:49:08 +03:30
{
2026-07-21 14:13:26 +03:30
$lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 90);
if (! $lock->get()) {
return [
'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => ['همگام‌سازی در حال اجراست'], 'logs' => []],
'mande' => ['applied' => false, 'message' => 'همگام‌سازی دیگری برای این مشتری در حال اجراست — لطفاً صبر کنید'],
];
2026-07-21 12:49:08 +03:30
}
2026-07-21 14:13:26 +03:30
try {
// عمداً replay اسناد قدیمی نداریم — باعث شارژ چندباره می‌شد
return [
'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []],
'mande' => $this->applyLatestMandeDifference($customer, force: $forceMande),
];
} finally {
$lock->release();
}
2026-07-21 12:49:08 +03:30
}
/**
* @return array{applied: bool, gold_delta?: float, rial_delta?: float, message: string}
*/
2026-07-21 14:13:26 +03:30
public function applyLatestMandeDifference(Customer $customer, bool $force = false): array
2026-07-21 12:49:08 +03:30
{
2026-07-21 13:25:18 +03:30
if ($this->hasPendingOutboundWalletSync((int) $customer->id)) {
return [
'applied' => false,
'message' => 'سند ارسالی از پنل به پرگار هنوز تأیید نشده — برای جلوگیری از برگشت شارژ، ماندهٔ پرگار اعمال نشد',
];
}
2026-07-21 12:49:08 +03:30
$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) ?: [];
2026-07-21 14:13:26 +03:30
if (! $force && ! empty($data['wallet_applied_from_mande'])) {
2026-07-21 12:49:08 +03:30
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;
$goldDiff = $goldInv - (float) ($wallet->gold ?? 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 | JSON_PRETTY_PRINT);
$log->save();
return ['applied' => false, 'message' => 'اختلافی بین ماندهٔ پرگار و کیف اپ نیست'];
}
$input = [
'title' => 'تطبیق ماندهٔ پرگار',
2026-07-21 14:13:26 +03:30
'note' => 'mande-log:'.$log->id,
2026-07-21 12:49:08 +03:30
'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;
2026-07-21 14:13:26 +03:30
$data['applied_at'] = now()->toDateTimeString();
2026-07-21 12:49:08 +03:30
$log->data = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ';
$log->save();
$this->walletBroadcast->broadcastForCustomer((int) $customer->id, $ownerType);
return [
'applied' => true,
'gold_delta' => $goldDiff,
'rial_delta' => $rialDiff,
'message' => 'ماندهٔ پرگار روی کیف اعمال شد',
];
}
/**
2026-07-21 14:13:26 +03:30
* @param array<string, mixed> $tx
2026-07-21 12:49:08 +03:30
*/
2026-07-21 14:13:26 +03:30
public function transactionFingerprint(string $accountId, array $tx): string
2026-07-21 12:49:08 +03:30
{
2026-07-21 14:13:26 +03:30
// بدون 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),
2026-07-21 12:49:08 +03:30
];
2026-07-21 14:13:26 +03:30
return 'fp:'.hash('sha256', implode('|', $parts));
2026-07-21 12:49:08 +03:30
}
2026-07-21 14:13:26 +03:30
public function isFingerprintApplied(string $fingerprint): bool
2026-07-21 12:36:59 +03:30
{
2026-07-21 14:13:26 +03:30
if ($fingerprint === '' || isset($this->seenFingerprints[$fingerprint])) {
return true;
2026-07-21 12:36:59 +03:30
}
2026-07-21 14:13:26 +03:30
return PargarSyncLog::query()
2026-07-21 12:36:59 +03:30
->where('type', PargarSyncService::TYPE_DOCUMENT)
2026-07-21 14:13:26 +03:30
->where('pargar_ref', $fingerprint)
2026-07-21 12:36:59 +03:30
->where('sync_status', PargarSyncService::STATUS_SUCCESS)
2026-07-21 14:13:26 +03:30
->exists();
}
2026-07-21 12:49:08 +03:30
2026-07-21 14:13:26 +03:30
/**
* @param array<string, mixed> $tx
*/
private function recordProcessedFingerprint(
Customer $customer,
string $fingerprint,
string $accountId,
array $tx,
bool $walletApplied,
string $reason
): void {
if ($this->isFingerprintApplied($fingerprint) && ! isset($this->seenFingerprints[$fingerprint])) {
return;
2026-07-21 12:49:08 +03:30
}
2026-07-21 14:13:26 +03:30
// اگر فقط در حافظه است و هنوز در DB نیست، برای skipها هم رکورد بساز تا تکرار نشود
if (PargarSyncLog::query()
->where('type', PargarSyncService::TYPE_DOCUMENT)
->where('pargar_ref', $fingerprint)
->exists()) {
return;
}
PargarSyncLog::create([
'customer_id' => $customer->id,
'desc' => $walletApplied
? 'اعمال سند پرگار'
: ($reason === 'echo_outbound'
? 'سند پرگار — قبلاً از پنل ارسال شده (بدون اعمال مجدد)'
: 'سند پرگار — رد شده'),
'type' => PargarSyncService::TYPE_DOCUMENT,
'sync_status' => PargarSyncService::STATUS_SUCCESS,
'pargar_ref' => $fingerprint,
'data' => json_encode([
'accountId' => $accountId,
'transaction' => $tx,
'wallet_applied' => $walletApplied,
'fingerprint' => $fingerprint,
'skip_reason' => $walletApplied ? null : $reason,
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
]);
2026-07-21 12:36:59 +03:30
}
/**
* @param array<string, mixed> $tx
*/
private function applyTransactionDelta(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' => $title,
'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
);
}
/**
* @param array<string, mixed> $tx
*/
private function transactionGoldDelta(array $tx): float
{
2026-07-21 14:13:26 +03:30
return $this->parseNumber($tx['besGold'] ?? 0) - $this->parseNumber($tx['bedGold'] ?? 0);
2026-07-21 12:36:59 +03:30
}
/**
* @param array<string, mixed> $tx
*/
private function transactionRialDelta(array $tx): float
{
2026-07-21 14:13:26 +03:30
return $this->parseNumber($tx['besRial'] ?? 0) - $this->parseNumber($tx['bedRial'] ?? 0);
2026-07-21 12:36:59 +03:30
}
private function isEchoOfRecentOutbound(int $customerId, float $goldDelta, float $rialDelta): bool
{
$since = now()->subDays(3);
$logs = PargarSyncLog::query()
->where('customer_id', $customerId)
->whereIn('type', [PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD])
->where('created_at', '>=', $since)
->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;
}
2026-07-21 13:25:18 +03:30
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();
}
2026-07-21 14:13:26 +03:30
private function normalizeNumberString(mixed $value): string
{
return (string) $this->parseNumber($value);
}
2026-07-21 12:36:59 +03:30
private function parseNumber(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
return (float) str_replace('/', '.', (string) $value);
}
}