fix pargar mande
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user