2026-07-21 12:36:59 +03:30
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Models\Customer;
|
|
|
|
|
use App\Models\PargarSyncLog;
|
2026-07-25 12:02:58 +03:30
|
|
|
use Illuminate\Support\Carbon;
|
2026-07-21 14:13:26 +03:30
|
|
|
use Illuminate\Support\Facades\Cache;
|
2026-07-26 14:15:26 +03:30
|
|
|
use Illuminate\Support\Facades\DB;
|
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-26 14:15:26 +03:30
|
|
|
use Rooyesh\Wallet\models\Transaction;
|
2026-07-21 12:36:59 +03:30
|
|
|
|
|
|
|
|
/**
|
2026-07-22 01:05:00 +03:30
|
|
|
* همگامسازی کیف با پرگار.
|
2026-07-21 14:13:26 +03:30
|
|
|
*
|
2026-07-21 14:33:16 +03:30
|
|
|
* قواعد سخت:
|
2026-07-22 01:05:00 +03:30
|
|
|
* 1) webhook اسناد inbound فقط لاگ میکند؛ کیف را با replay تاریخچه عوض نمیکند.
|
2026-07-25 12:02:58 +03:30
|
|
|
* 2) دامنهٔ مانده: RialInv = کیف ریالی، GoldInv = طلای کیف اصلی (main_wallet_products).
|
|
|
|
|
* محصولات خارج از کیف اصلی هرگز از مانده اثر نمیگیرند.
|
|
|
|
|
* 3) ماندهٔ کهنه دور ریخته میشود، نه معوق: اگر سند ما هنوز pending باشد یا snapshot
|
|
|
|
|
* پیش از ثبت سند گرفته شده باشد، اعمال نمیشود و منتظر ماندهٔ بعدی میمانیم.
|
|
|
|
|
* 4) اعمال فقط با دو snapshot پیاپیِ هممقدار — تا ماندهای که وسط ثبت اسناد گرفته شده
|
|
|
|
|
* موجودی مشتری را عقب نبرد.
|
2026-07-26 14:15:26 +03:30
|
|
|
* 5) اگر واریز/برداشت ریالیای اصلاً سند پرگار نداشته باشد، مانده اعمال نمیشود؛
|
|
|
|
|
* پرگار از آن پول خبر ندارد و اعمال مانده موجودی مشتری را پاک میکند.
|
|
|
|
|
* 6) دکمهٔ ادمین با force میتواند همهٔ گیتهای بالا را رد کند (ابزار اصلاح دستی).
|
2026-07-21 12:36:59 +03:30
|
|
|
*/
|
|
|
|
|
class PargarInboundWalletService
|
|
|
|
|
{
|
|
|
|
|
public const TX_TYPE_PARGAR = 22;
|
|
|
|
|
|
2026-07-21 14:23:26 +03:30
|
|
|
public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled';
|
2026-07-21 14:13:26 +03:30
|
|
|
|
2026-07-26 14:15:26 +03:30
|
|
|
/** واریز و برداشت ریالی — دو نوعی که باید سند ریالی پرگار داشته باشند */
|
|
|
|
|
public const CASH_DOCUMENT_TX_TYPES = [3, 4];
|
|
|
|
|
|
2026-07-21 21:38:48 +03:30
|
|
|
/** سقف تعداد دسته برای علامتزدن در هر کلیک */
|
|
|
|
|
private const MAX_BATCHES_PER_SYNC = 50;
|
2026-07-21 14:33:16 +03:30
|
|
|
|
2026-07-21 12:36:59 +03:30
|
|
|
public function __construct(
|
|
|
|
|
private readonly WalletBroadcastService $walletBroadcast
|
|
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 14:23:26 +03:30
|
|
|
public static function isApplyFrozen(): bool
|
|
|
|
|
{
|
2026-07-21 14:33:16 +03:30
|
|
|
return (bool) Cache::get(self::KILL_SWITCH_CACHE_KEY, false);
|
2026-07-21 14:23:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function freezeApplies(bool $frozen = true): void
|
|
|
|
|
{
|
|
|
|
|
Cache::forever(self::KILL_SWITCH_CACHE_KEY, $frozen);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 12:36:59 +03:30
|
|
|
/**
|
2026-07-22 01:05:00 +03:30
|
|
|
* آیا سندی از پنل در صف ثبت داخل پرگار است؟ (کیف یا سفارش)
|
|
|
|
|
* در این حالت مانده را اعمال نمیکنیم تا بعد از ثبت سند، ماندهٔ درست بیاید.
|
|
|
|
|
*/
|
|
|
|
|
public function hasPendingOutboundToPargar(Customer $customer): bool
|
|
|
|
|
{
|
|
|
|
|
return PargarSyncLog::query()
|
|
|
|
|
->where('customer_id', $customer->id)
|
|
|
|
|
->whereIn('type', [
|
|
|
|
|
PargarSyncService::TYPE_WALLET_CASH,
|
|
|
|
|
PargarSyncService::TYPE_WALLET_GOLD,
|
|
|
|
|
PargarSyncService::TYPE_ORDER,
|
|
|
|
|
])
|
|
|
|
|
->where('sync_status', PargarSyncService::STATUS_PENDING)
|
|
|
|
|
->exists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* دکمهٔ ادمین / همگامسازی خودکار:
|
|
|
|
|
* ۱) علامت مصرف دستههای سند inbound (بدون نوشتن روی کیف)
|
2026-07-21 21:38:48 +03:30
|
|
|
* ۲) همترازی مانده با آخرین لاگ mande
|
2026-07-21 14:33:16 +03:30
|
|
|
*
|
2026-07-25 12:02:58 +03:30
|
|
|
* @param bool $forceMande اصلاح دستی ادمین: گیتهای اعتبارسنجی مانده رد شوند
|
|
|
|
|
* @param bool $auto مسیر خودکار (webhook/صف) — همیشه با گیت کامل
|
2026-07-21 14:13:26 +03:30
|
|
|
* @return array{
|
2026-07-21 21:38:48 +03:30
|
|
|
* documents: array{applied: int, skipped: int, batches: int, acknowledged: int, errors: list<string>},
|
2026-07-21 14:13:26 +03:30
|
|
|
* mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string}
|
|
|
|
|
* }
|
2026-07-21 12:49:08 +03:30
|
|
|
*/
|
2026-07-22 01:05:00 +03:30
|
|
|
public function syncWalletFromPargar(Customer $customer, bool $forceMande = false, bool $auto = false): array
|
2026-07-21 12:49:08 +03:30
|
|
|
{
|
2026-07-21 14:33:16 +03:30
|
|
|
if (self::isApplyFrozen()) {
|
2026-07-21 14:23:26 +03:30
|
|
|
return [
|
2026-07-21 21:38:48 +03:30
|
|
|
'documents' => [
|
|
|
|
|
'applied' => 0,
|
|
|
|
|
'skipped' => 0,
|
|
|
|
|
'batches' => 0,
|
|
|
|
|
'acknowledged' => 0,
|
|
|
|
|
'errors' => [],
|
|
|
|
|
],
|
2026-07-21 14:23:26 +03:30
|
|
|
'mande' => [
|
|
|
|
|
'applied' => false,
|
2026-07-21 14:33:16 +03:30
|
|
|
'message' => 'اعمال کیف یخزده است — php artisan pargar:wallet-freeze off',
|
2026-07-21 14:23:26 +03:30
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 21:38:48 +03:30
|
|
|
$lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 60);
|
2026-07-21 14:13:26 +03:30
|
|
|
if (! $lock->get()) {
|
|
|
|
|
return [
|
2026-07-21 21:38:48 +03:30
|
|
|
'documents' => [
|
|
|
|
|
'applied' => 0,
|
|
|
|
|
'skipped' => 0,
|
|
|
|
|
'batches' => 0,
|
|
|
|
|
'acknowledged' => 0,
|
|
|
|
|
'errors' => ['همگامسازی در حال اجراست'],
|
|
|
|
|
],
|
2026-07-21 14:33:16 +03:30
|
|
|
'mande' => ['applied' => false, 'message' => 'همگامسازی دیگری برای این مشتری در حال اجراست'],
|
2026-07-21 14:13:26 +03:30
|
|
|
];
|
2026-07-21 12:49:08 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-21 14:13:26 +03:30
|
|
|
try {
|
2026-07-22 01:05:00 +03:30
|
|
|
$this->attachOrphanDocumentLogs($customer);
|
|
|
|
|
$this->attachOrphanMandeLogs($customer);
|
|
|
|
|
|
|
|
|
|
// ۱) تاریخچهٔ اسناد inbound را روی کیف replay نکن — فقط مصرفشده علامت بزن
|
2026-07-21 21:38:48 +03:30
|
|
|
$documents = $this->acknowledgePendingDocumentBatches($customer);
|
2026-07-21 14:33:16 +03:30
|
|
|
|
2026-07-25 12:02:58 +03:30
|
|
|
// ۲) منبع حقیقت موجودی = ماندهٔ پرگار، اما فقط ماندهٔ معتبر
|
2026-07-22 01:05:00 +03:30
|
|
|
$mande = $this->applyLatestMandeDifference(
|
|
|
|
|
$customer,
|
2026-07-25 12:02:58 +03:30
|
|
|
force: ! $auto && $forceMande
|
2026-07-22 01:05:00 +03:30
|
|
|
);
|
2026-07-21 14:33:16 +03:30
|
|
|
|
2026-07-21 21:38:48 +03:30
|
|
|
if (($documents['acknowledged'] ?? 0) > 0 || ($mande['applied'] ?? false)) {
|
2026-07-21 14:33:16 +03:30
|
|
|
$this->walletBroadcast->broadcastForCustomer(
|
|
|
|
|
(int) $customer->id,
|
|
|
|
|
(int) ($customer->type ?? 2)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 14:13:26 +03:30
|
|
|
return [
|
2026-07-21 14:33:16 +03:30
|
|
|
'documents' => $documents,
|
|
|
|
|
'mande' => $mande,
|
2026-07-21 14:13:26 +03:30
|
|
|
];
|
|
|
|
|
} finally {
|
|
|
|
|
$lock->release();
|
|
|
|
|
}
|
2026-07-21 12:49:08 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-21 14:33:16 +03:30
|
|
|
/**
|
2026-07-21 21:38:48 +03:30
|
|
|
* دستههای ورودی پرگار را بدون تغییر کیف، مصرفشده علامت میزند.
|
|
|
|
|
* (اعمال تکتک bed/bes تاریخچه = تورم کیف و 504)
|
2026-07-21 14:33:16 +03:30
|
|
|
*
|
2026-07-21 21:38:48 +03:30
|
|
|
* @return array{applied: int, skipped: int, batches: int, acknowledged: int, errors: list<string>}
|
2026-07-21 14:33:16 +03:30
|
|
|
*/
|
2026-07-21 21:38:48 +03:30
|
|
|
public function acknowledgePendingDocumentBatches(Customer $customer): array
|
2026-07-21 14:33:16 +03:30
|
|
|
{
|
|
|
|
|
$result = [
|
|
|
|
|
'applied' => 0,
|
|
|
|
|
'skipped' => 0,
|
|
|
|
|
'batches' => 0,
|
2026-07-21 21:38:48 +03:30
|
|
|
'acknowledged' => 0,
|
2026-07-21 14:33:16 +03:30
|
|
|
'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']++;
|
2026-07-21 21:38:48 +03:30
|
|
|
$txCount = count($data['transactions']);
|
|
|
|
|
$result['skipped'] += $txCount;
|
2026-07-21 14:33:16 +03:30
|
|
|
|
2026-07-21 21:31:16 +03:30
|
|
|
try {
|
2026-07-21 21:38:48 +03:30
|
|
|
$data['wallet_batch_ack_only'] = true;
|
|
|
|
|
$data['wallet_batch_ack_reason'] = 'mande_is_source_of_truth';
|
2026-07-21 21:31:16 +03:30
|
|
|
$this->markDocumentBatchApplied($batch, $data);
|
2026-07-21 21:38:48 +03:30
|
|
|
$result['acknowledged']++;
|
2026-07-21 21:31:16 +03:30
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$result['errors'][] = 'علامت دسته #'.$batch->id.' ناموفق: '.$e->getMessage();
|
2026-07-21 21:38:48 +03:30
|
|
|
Log::error('Pargar acknowledgeDocumentBatch failed', [
|
2026-07-21 21:31:16 +03:30
|
|
|
'log_id' => $batch->id,
|
|
|
|
|
'customer_id' => $customer->id,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
2026-07-21 14:33:16 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 21:31:16 +03:30
|
|
|
/**
|
2026-07-21 21:38:48 +03:30
|
|
|
* @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 فشرده مینویسد.
|
2026-07-21 21:31:16 +03:30
|
|
|
*
|
|
|
|
|
* @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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 21:38:48 +03:30
|
|
|
// ack-only: همیشه stub فشرده — نیازی به نگهداشتن صدها سطر تاریخچه نیست
|
|
|
|
|
if (! empty($data['wallet_batch_ack_only'])) {
|
|
|
|
|
$batch->data = $this->encodeAppliedBatchCompact($data);
|
|
|
|
|
$batch->save();
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 21:31:16 +03:30
|
|
|
$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(),
|
2026-07-21 21:38:48 +03:30
|
|
|
'wallet_batch_ack_only' => true,
|
2026-07-21 21:31:16 +03:30
|
|
|
'payload_compacted' => true,
|
|
|
|
|
], JSON_UNESCAPED_UNICODE);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 12:49:08 +03:30
|
|
|
/**
|
2026-07-25 12:02:58 +03:30
|
|
|
* ماندهٔ پرگار روی کیف اپ:
|
|
|
|
|
* RialInv → کیف ریالی، GoldInv → طلای کیف اصلی (main_wallet_products).
|
|
|
|
|
* محصولات خارج از کیف اصلی (قلک سرمایهگذاری، سکه و …) هیچ سهمی در مانده ندارند و
|
|
|
|
|
* هرگز از آن اثر نمیگیرند.
|
|
|
|
|
*
|
|
|
|
|
* @param bool $force اعمال دستی ادمین — گیتهای اعتبارسنجی نادیده گرفته میشوند
|
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
|
|
|
{
|
|
|
|
|
$log = PargarSyncLog::query()
|
|
|
|
|
->where('customer_id', $customer->id)
|
|
|
|
|
->where('type', PargarSyncService::TYPE_MANDE)
|
|
|
|
|
->orderByDesc('id')
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
if ($log === null) {
|
2026-07-21 14:33:16 +03:30
|
|
|
return ['applied' => false, 'message' => 'لاگ ماندهٔ پرگار برای این مشتری نیست'];
|
2026-07-21 12:49:08 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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 14:33:16 +03:30
|
|
|
return ['applied' => false, 'message' => 'مانده قبلاً همتراز شده است'];
|
2026-07-21 12:49:08 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-25 12:02:58 +03:30
|
|
|
if (! $force) {
|
|
|
|
|
$block = $this->mandeBlockReason($customer, $log, $data);
|
|
|
|
|
if ($block !== null) {
|
|
|
|
|
$this->markMandeSkipped($log, $data, $block['reason']);
|
|
|
|
|
|
|
|
|
|
return ['applied' => false, 'message' => $block['message']];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 12:49:08 +03:30
|
|
|
$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;
|
|
|
|
|
|
2026-07-25 12:02:58 +03:30
|
|
|
$mainGold = app(WalletBalanceService::class)
|
|
|
|
|
->effectiveMainWalletGold((int) $customer->id, $ownerType);
|
2026-07-21 22:23:09 +03:30
|
|
|
|
|
|
|
|
$goldDiff = $goldInv - $mainGold;
|
2026-07-21 12:49:08 +03:30
|
|
|
$rialDiff = $rialInv - (float) ($wallet->cash ?? 0);
|
|
|
|
|
|
|
|
|
|
if (abs($goldDiff) < 0.01 && abs($rialDiff) < 1) {
|
|
|
|
|
$data['wallet_applied_from_mande'] = true;
|
2026-07-21 21:31:16 +03:30
|
|
|
$log->data = json_encode($data, JSON_UNESCAPED_UNICODE);
|
2026-07-21 12:49:08 +03:30
|
|
|
$log->save();
|
|
|
|
|
|
2026-07-21 14:33:16 +03:30
|
|
|
return ['applied' => false, 'message' => 'مانده با پرگار یکسان است'];
|
2026-07-21 12:49:08 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 14:15:26 +03:30
|
|
|
// مانده پرگار میتواند منفی باشد و کیف باید همان را نشان دهد؛ کف موجودی اینجا اعمال نمیشود
|
2026-07-21 12:49:08 +03:30
|
|
|
new TransactionInterface(
|
|
|
|
|
$walletInterface,
|
|
|
|
|
$input,
|
|
|
|
|
self::TX_TYPE_PARGAR,
|
|
|
|
|
$goldDiff > 0,
|
2026-07-26 14:15:26 +03:30
|
|
|
$rialDiff > 0,
|
|
|
|
|
0,
|
|
|
|
|
[],
|
|
|
|
|
true
|
2026-07-21 12:49:08 +03:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$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-25 12:02:58 +03:30
|
|
|
$data['applied_forced'] = $force;
|
2026-07-21 22:23:09 +03:30
|
|
|
$data['main_gold_before'] = $mainGold;
|
2026-07-25 12:02:58 +03:30
|
|
|
unset($data['mande_skip_reason'], $data['mande_skipped_at']);
|
2026-07-21 21:31:16 +03:30
|
|
|
$log->data = json_encode($data, JSON_UNESCAPED_UNICODE);
|
2026-07-21 12:49:08 +03:30
|
|
|
$log->desc = 'اعمال ماندهٔ پرگار روی کیف اپ';
|
|
|
|
|
$log->save();
|
|
|
|
|
|
2026-07-25 12:02:58 +03:30
|
|
|
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,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-21 12:49:08 +03:30
|
|
|
return [
|
|
|
|
|
'applied' => true,
|
|
|
|
|
'gold_delta' => $goldDiff,
|
|
|
|
|
'rial_delta' => $rialDiff,
|
2026-07-21 14:33:16 +03:30
|
|
|
'message' => 'مانده با پرگار همتراز شد',
|
2026-07-21 12:49:08 +03:30
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 22:23:09 +03:30
|
|
|
/**
|
2026-07-25 12:02:58 +03:30
|
|
|
* ماندهٔ پرگار فقط وقتی معتبر است که همهٔ اسناد ما در پرگار ثبت شده باشند و
|
|
|
|
|
* snapshot بعد از آن ثبت گرفته شده باشد. اعمال ماندهٔ قدیمی یعنی برگرداندن
|
|
|
|
|
* موجودی مشتری به قبل از سند — همان «وزن/ریالم کم شد».
|
|
|
|
|
*
|
|
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
* @return array{reason: string, message: string}|null null یعنی معتبر
|
2026-07-21 22:23:09 +03:30
|
|
|
*/
|
2026-07-25 12:02:58 +03:30
|
|
|
private function mandeBlockReason(Customer $customer, PargarSyncLog $log, array $data): ?array
|
2026-07-21 22:23:09 +03:30
|
|
|
{
|
2026-07-25 12:02:58 +03:30
|
|
|
if ($this->hasPendingOutboundToPargar($customer)) {
|
|
|
|
|
return [
|
|
|
|
|
'reason' => 'pending_outbound',
|
|
|
|
|
'message' => 'سند پنل هنوز در پرگار ثبت نشده — مانده اعمال نمیشود',
|
|
|
|
|
];
|
2026-07-21 22:23:09 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-25 12:02:58 +03:30
|
|
|
$lastConfirmedAt = $this->lastOutboundConfirmedAt($customer);
|
|
|
|
|
$takenAt = $log->created_at;
|
|
|
|
|
|
|
|
|
|
if ($lastConfirmedAt !== null && ($takenAt === null || $takenAt->lte($lastConfirmedAt))) {
|
|
|
|
|
return [
|
|
|
|
|
'reason' => 'taken_before_document',
|
|
|
|
|
'message' => 'این مانده پیش از ثبت آخرین سند گرفته شده — نادیده گرفته شد',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 14:15:26 +03:30
|
|
|
$missing = $this->unexportedCashMovement($customer, $lastConfirmedAt);
|
|
|
|
|
if ($missing !== null) {
|
|
|
|
|
return [
|
|
|
|
|
'reason' => 'unexported_wallet_movement',
|
|
|
|
|
'message' => 'حرکت ریالی '
|
|
|
|
|
. number_format($missing['amount'])
|
|
|
|
|
. ' ریال هنوز سندی در پرگار ندارد — تا ثبت سند، مانده اعمال نمیشود',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 12:02:58 +03:30
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 14:15:26 +03:30
|
|
|
/**
|
|
|
|
|
* واریز/برداشت ریالی که سند پرگارش ساخته نشده است.
|
|
|
|
|
*
|
|
|
|
|
* تا وقتی پرگار از این حرکت خبر ندارد، ماندهٔ آن هم شامل این پول نیست؛ اعمال آن
|
|
|
|
|
* موجودی مشتری را پاک میکند. پس مانده را نگه میداریم تا سند ساخته و ثبت شود.
|
|
|
|
|
*
|
|
|
|
|
* @return array{amount: float, withdraw_ids: list<int>}|null
|
|
|
|
|
*/
|
|
|
|
|
private function unexportedCashMovement(Customer $customer, ?Carbon $lastConfirmedAt): ?array
|
|
|
|
|
{
|
|
|
|
|
$since = $this->cashMovementScanStart($customer, $lastConfirmedAt);
|
|
|
|
|
if ($since === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$rows = Transaction::query()
|
|
|
|
|
->where('customer_id', $customer->id)
|
|
|
|
|
->whereIn('type', self::CASH_DOCUMENT_TX_TYPES)
|
|
|
|
|
->whereNotNull('withdraw_id')
|
|
|
|
|
->where('created_at', '>', $since)
|
|
|
|
|
->where(function ($q) {
|
|
|
|
|
$q->where('cash', '>=', 1)->orWhere('cash', '<=', -1);
|
|
|
|
|
})
|
|
|
|
|
->whereNotExists(function ($q) {
|
|
|
|
|
$q->select(DB::raw(1))
|
|
|
|
|
->from('pargar_sync_logs')
|
|
|
|
|
->whereColumn('pargar_sync_logs.withdraw_id', 'transactions.withdraw_id')
|
|
|
|
|
->where('pargar_sync_logs.type', PargarSyncService::TYPE_WALLET_CASH);
|
|
|
|
|
})
|
|
|
|
|
->get(['withdraw_id', 'cash']);
|
|
|
|
|
|
|
|
|
|
if ($rows->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'amount' => (float) $rows->sum(fn ($row) => abs((float) $row->cash)),
|
|
|
|
|
'withdraw_ids' => $rows->pluck('withdraw_id')->map(fn ($id) => (int) $id)->all(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* از کجا دنبال سند جاافتاده بگردیم: بعد از آخرین سند ثبتشده، و اگر نبود از
|
|
|
|
|
* شروع اتصال همین مشتری به پرگار. تراکنشهای قبل از اتصال سند ندارند و نباید
|
|
|
|
|
* سینک را برای همیشه قفل کنند.
|
|
|
|
|
*/
|
|
|
|
|
private function cashMovementScanStart(Customer $customer, ?Carbon $lastConfirmedAt): ?Carbon
|
|
|
|
|
{
|
|
|
|
|
$firstLogAt = PargarSyncLog::query()
|
|
|
|
|
->where('customer_id', $customer->id)
|
|
|
|
|
->min('created_at');
|
|
|
|
|
|
|
|
|
|
$integrationStart = $firstLogAt !== null ? Carbon::parse($firstLogAt) : null;
|
|
|
|
|
|
|
|
|
|
if ($lastConfirmedAt !== null && $integrationStart !== null) {
|
|
|
|
|
return $lastConfirmedAt->greaterThan($integrationStart) ? $lastConfirmedAt : $integrationStart;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $lastConfirmedAt ?? $integrationStart;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 12:02:58 +03:30
|
|
|
/**
|
|
|
|
|
* ماندهٔ صفرِ هر دو فیلد برای مشتریای که کیفش خالی نیست، تقریباً همیشه یعنی
|
|
|
|
|
* پرگار حساب را پیدا نکرده — نه اینکه مشتری تسویه کرده باشد.
|
|
|
|
|
*
|
|
|
|
|
* @param array<string, mixed> $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<string, mixed>|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<string, mixed> $current
|
|
|
|
|
* @param array<string, mixed> $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<string, mixed> $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();
|
2026-07-21 22:23:09 +03:30
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-07-22 00:31:38 +03:30
|
|
|
|
|
|
|
|
/**
|
2026-07-22 01:05:00 +03:30
|
|
|
* همگامسازی خودکار در پسزمینه — بعد از مانده / تأیید سند در پرگار / import.
|
2026-07-22 00:31:38 +03:30
|
|
|
*/
|
|
|
|
|
public function queueWalletSyncAfterResponse(int $customerId): void
|
|
|
|
|
{
|
|
|
|
|
dispatch(function () use ($customerId) {
|
|
|
|
|
$customer = Customer::find($customerId);
|
|
|
|
|
if ($customer === null || ! $customer->hasValidPargarAccountId()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-22 01:05:00 +03:30
|
|
|
$this->syncWalletFromPargar($customer, forceMande: false, auto: true);
|
2026-07-22 00:31:38 +03:30
|
|
|
} 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]);
|
|
|
|
|
}
|
2026-07-22 01:05:00 +03:30
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* اسناد یتیم (قبل از لینک مشتری) را به customer_id وصل میکند.
|
|
|
|
|
*/
|
|
|
|
|
public function attachOrphanDocumentLogs(Customer $customer): int
|
|
|
|
|
{
|
|
|
|
|
if (! $customer->hasValidPargarAccountId()) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$accountId = trim((string) $customer->pargar_account_id);
|
|
|
|
|
|
|
|
|
|
return PargarSyncLog::query()
|
|
|
|
|
->where('type', PargarSyncService::TYPE_DOCUMENT)
|
|
|
|
|
->where('pargar_ref', $accountId)
|
|
|
|
|
->where(function ($q) {
|
|
|
|
|
$q->whereNull('customer_id')->orWhere('customer_id', 0);
|
|
|
|
|
})
|
|
|
|
|
->update(['customer_id' => $customer->id]);
|
|
|
|
|
}
|
2026-07-21 12:36:59 +03:30
|
|
|
}
|