@@ -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