fix pargar mande

This commit is contained in:
2026-07-25 12:02:58 +03:30
parent 52ff3a088d
commit 22a5e566b4
5 changed files with 251 additions and 58 deletions
@@ -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([
+22
View File
@@ -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);
+189 -56
View File
@@ -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<string>},
* 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<string, mixed> $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<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();
}
private function parseNumber(mixed $value): float
+5
View File
@@ -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);
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('pargar_sync_logs') || Schema::hasColumn('pargar_sync_logs', 'confirmed_at')) {
return;
}
Schema::table('pargar_sync_logs', function (Blueprint $table) {
$table->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');
});
}
};