From 93ebbb1fc87ea46aa54bfb2451fd9505f7e9702c Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Sun, 4 Oct 2026 01:01:20 +0330 Subject: [PATCH] feat(finance): implement multi-currency credit tracking and dashboard widgets Integrate multi-currency debt visibility across the platform by adding support for foreign currency credits in the user profile shortcode, improving the admin dashboard with unsettled credits widgets, and enhancing the Filament admin panel with navigation badges and relation managers. - Add multi-currency debt table to WordPress user profile shortcode - Implement `UnsettledCreditsWidget` for Filament dashboard - Add navigation badge to `CustomerCreditResource` for open credits - Add `CreditsRelationManager` to `UserResource` - Update `CustomerOrderController` API to include `foreign_credits` - Add concurrency check in `CustomerCreditService` during settlement --- .../ifnex-bridge/includes/shortcodes.php | 147 ++++++++++++++++++ 04_Laravel/app/Filament/Pages/Dashboard.php | 7 +- .../Resources/CustomerCreditResource.php | 14 ++ .../app/Filament/Resources/UserResource.php | 5 +- .../CreditsRelationManager.php | 116 ++++++++++++++ .../Widgets/UnsettledCreditsWidget.php | 81 ++++++++++ .../Api/Customer/CustomerOrderController.php | 11 ++ .../Providers/Filament/AdminPanelProvider.php | 1 + .../app/Services/CustomerCreditService.php | 9 ++ .../widgets/unsettled-credits.blade.php | 109 +++++++++++++ 10 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 04_Laravel/app/Filament/Resources/UserResource/RelationManagers/CreditsRelationManager.php create mode 100644 04_Laravel/app/Filament/Widgets/UnsettledCreditsWidget.php create mode 100644 04_Laravel/resources/views/filament/widgets/unsettled-credits.blade.php diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php index 0817dc5..329327a 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php @@ -252,7 +252,87 @@ function ifnex_user_profile_shortcode($atts) { + + +
+
+ 🌍 بدهی‌های چندارزی +
+ + + + + + + + + + + + + + + + + + + +
ارزمبلغ کلتسویه‌شدهمانده
+ + + + + + + + + +
+
+ ⚠️ بدهی‌های بالا به ارز اصلی ثبت شده‌اند و با نرخ روز تسویه می‌شوند. +
+
+ + + +
+
+ 🌍 بدهی‌های چندارزی +
+ + + + + + + + + + + + + + + + + + + +
ارزمبلغ کلتسویه‌شدهمانده
+ + + + + + + + + +
+
+ +
@@ -1749,6 +1829,73 @@ function ifnex_customer_dashboard_shortcode($atts) { مشاهده تراکنش‌ها
+ + + 0): ?> +
+
+ ⚠️ بدهی در انتظار پرداخت +
+
+ ریال +
+
+ سفارش تأییدشده در انتظار پرداخت +
+
+ + + + +
+
+ 🌍 بدهی‌های چندارزی +
+ + + + + + + + + + + + + + + + + + + +
ارزمبلغ کلتسویه‌شدهمانده بدهی
+ + + + + + + + + +
+
+ ℹ️ این بدهی‌ها به ارز اصلی ثبت شده‌اند و با نرخ روز تسویه می‌شوند. + برای تسویه با مدیر تماس بگیرید. +
+
+ + + +
+
مانده حساب (موجودی منهای بدهی‌های ریالی)
+
+ ریال +
+
+ diff --git a/04_Laravel/app/Filament/Pages/Dashboard.php b/04_Laravel/app/Filament/Pages/Dashboard.php index a6947f3..dbcb1e4 100644 --- a/04_Laravel/app/Filament/Pages/Dashboard.php +++ b/04_Laravel/app/Filament/Pages/Dashboard.php @@ -9,6 +9,8 @@ use App\Filament\Widgets\RecentShipmentsWidget; use App\Filament\Widgets\RecentTransactionsWidget; use App\Filament\Widgets\TransactionChartWidget; use Filament\Pages\Dashboard as BaseDashboard; +use App\Filament\Widgets\UnsettledCreditsWidget; + /** * IFNEX Admin Dashboard — layered layout (12-column grid) @@ -42,8 +44,9 @@ class Dashboard extends BaseDashboard public function getHeaderWidgets(): array { return [ - FinanceOverviewWidget::class, - DashboardInfoWidget::class, + FinanceOverviewWidget::class, + DashboardInfoWidget::class, + UnsettledCreditsWidget::class, ]; } diff --git a/04_Laravel/app/Filament/Resources/CustomerCreditResource.php b/04_Laravel/app/Filament/Resources/CustomerCreditResource.php index ca61c4e..ec8cf6f 100644 --- a/04_Laravel/app/Filament/Resources/CustomerCreditResource.php +++ b/04_Laravel/app/Filament/Resources/CustomerCreditResource.php @@ -296,6 +296,20 @@ class CustomerCreditResource extends Resource ]; } + /** + * Badge روی منوی سایدبار — تعداد اعتبارهای باز (تسویه‌نشده). + */ + public static function getNavigationBadge(): ?string + { + $count = \App\Models\CustomerCredit::whereIn('status', ['open', 'partially_settled'])->count(); + return $count > 0 ? (string) ifnex_persian_digits(number_format($count)) : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'danger'; + } + // ─── Authorization ──────────────────────────────── public static function canViewAny(): bool diff --git a/04_Laravel/app/Filament/Resources/UserResource.php b/04_Laravel/app/Filament/Resources/UserResource.php index 2c1246f..93217e1 100644 --- a/04_Laravel/app/Filament/Resources/UserResource.php +++ b/04_Laravel/app/Filament/Resources/UserResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources; use App\Filament\Resources\UserResource\Pages; +use App\Filament\Resources\UserResource\RelationManagers; use App\Models\User; use Filament\Forms; use Filament\Forms\Form; @@ -206,7 +207,9 @@ class UserResource extends Resource public static function getRelations(): array { - return []; + return [ + RelationManagers\CreditsRelationManager::class, + ]; } public static function getPages(): array diff --git a/04_Laravel/app/Filament/Resources/UserResource/RelationManagers/CreditsRelationManager.php b/04_Laravel/app/Filament/Resources/UserResource/RelationManagers/CreditsRelationManager.php new file mode 100644 index 0000000..028fa3f --- /dev/null +++ b/04_Laravel/app/Filament/Resources/UserResource/RelationManagers/CreditsRelationManager.php @@ -0,0 +1,116 @@ +schema([ + Forms\Components\TextInput::make('currency_code') + ->label('ارز') + ->disabled(), + Forms\Components\TextInput::make('amount') + ->label('مبلغ کل') + ->disabled(), + Forms\Components\TextInput::make('settled_amount') + ->label('تسویه‌شده') + ->disabled(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('currency_code') + ->label('ارز') + ->badge() + ->color('info'), + + Tables\Columns\TextColumn::make('amount') + ->label('مبلغ کل') + ->numeric(2) + ->sortable(), + + Tables\Columns\TextColumn::make('settled_amount') + ->label('تسویه‌شده') + ->numeric(2) + ->color('success'), + + Tables\Columns\TextColumn::make('balance') + ->label('مانده') + ->numeric(2) + ->badge() + ->color(fn ($record) => $record->balance > 0 ? 'danger' : 'gray'), + + Tables\Columns\TextColumn::make('status') + ->label('وضعیت') + ->badge() + ->formatStateUsing(fn (string $state) => match ($state) { + 'open' => 'باز', + 'partially_settled' => 'نیمه‌تسویه', + 'settled' => 'تسویه‌شده', + default => $state, + }) + ->color(fn (string $state) => match ($state) { + 'open' => 'danger', + 'partially_settled' => 'warning', + 'settled' => 'success', + default => 'gray', + }), + + Tables\Columns\TextColumn::make('reason') + ->label('دلیل') + ->limit(40) + ->toggleable(), + + Tables\Columns\TextColumn::make('granted_at') + ->label('تاریخ اعطا') + ->dateTime('Y/m/d') + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->label('وضعیت') + ->options([ + 'open' => 'باز', + 'partially_settled' => 'نیمه‌تسویه', + 'settled' => 'تسویه‌شده', + ]), + Tables\Filters\SelectFilter::make('currency_code') + ->label('ارز') + ->options(fn () => CustomerCredit::select('currency_code') + ->distinct() + ->orderBy('currency_code') + ->pluck('currency_code', 'currency_code') + ->toArray()), + ]) + ->headerActions([ + // ایجاد اعتبار فقط از طریق CustomerCreditResource (با Policy) + Tables\Actions\Action::make('manage_credits') + ->label('مدیریت اعتبارها') + ->icon('heroicon-o-arrow-left') + ->url(fn () => route('filament.admin.resources.customer-credits.index')) + ->color('primary'), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Widgets/UnsettledCreditsWidget.php b/04_Laravel/app/Filament/Widgets/UnsettledCreditsWidget.php new file mode 100644 index 0000000..c5c98ed --- /dev/null +++ b/04_Laravel/app/Filament/Widgets/UnsettledCreditsWidget.php @@ -0,0 +1,81 @@ +select('currency_code') + ->selectRaw('SUM(amount) as total_amount') + ->selectRaw('SUM(settled_amount) as total_settled') + ->selectRaw('SUM(amount - settled_amount) as total_balance') + ->selectRaw('COUNT(*) as credit_count') + ->groupBy('currency_code') + ->orderBy('total_balance', 'desc') + ->get() + ->map(fn ($r) => [ + 'currency_code' => $r->currency_code, + 'total_amount' => (float) $r->total_amount, + 'total_settled' => (float) $r->total_settled, + 'total_balance' => (float) $r->total_balance, + 'credit_count' => (int) $r->credit_count, + ]); + + // ─── تعداد کل اعتبارهای باز ─── + $totalOpenCredits = CustomerCredit::whereIn('status', ['open', 'partially_settled'])->count(); + + // ─── تعداد مشتریان بدهکار ─── + $totalDebtors = CustomerCredit::whereIn('status', ['open', 'partially_settled']) + ->distinct('user_id') + ->count('user_id'); + + // ─── ۵ بدهکار برتر (بر اساس مانده کل به ارز) ─── + $topDebtors = CustomerCredit::whereIn('customer_credits.status', ['open', 'partially_settled']) + ->join('users', 'customer_credits.user_id', '=', 'users.id') + ->select( + 'users.id as user_id', + 'users.name as user_name', + 'users.phone as user_phone', + DB::raw('COUNT(customer_credits.id) as credit_count'), + DB::raw('GROUP_CONCAT(DISTINCT customer_credits.currency_code) as currencies'), + DB::raw('SUM(customer_credits.amount - customer_credits.settled_amount) as total_balance_sum'), + DB::raw('MAX(customer_credits.granted_at) as latest_grant') + ) + ->groupBy('users.id', 'users.name', 'users.phone') + ->orderBy('total_balance_sum', 'desc') + ->limit(5) + ->get(); + + return [ + 'currencySummary' => $currencySummary, + 'totalOpenCredits' => $totalOpenCredits, + 'totalDebtors' => $totalDebtors, + 'topDebtors' => $topDebtors, + 'hasDebts' => $currencySummary->isNotEmpty(), + ]; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php index 8e555f7..c2a4ea4 100644 --- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php +++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php @@ -719,6 +719,16 @@ class CustomerOrderController extends Controller $wallet_balance = $user->wallet ? $user->wallet->balance : 0; $account_balance = $wallet_balance - $pending_payments_total; + // بدهی‌های چندارزی (EUR/USD/AED/CNY) — به همان ارز خارجی، نه معادل ریالی + $foreign_credits = app(\App\Services\CustomerCreditService::class) + ->getCustomerDebtsByCurrency($user); + + $account_balance = $wallet_balance - $pending_payments_total; + + // بدهی‌های چندارزی — به همان ارز خارجی + $foreign_credits = app(\App\Services\CustomerCreditService::class) + ->getCustomerDebtsByCurrency($user); + // آمار سفارشات $stats = [ 'total_orders' => Shipment::where('user_id', $user->id)->count(), @@ -762,6 +772,7 @@ class CustomerOrderController extends Controller ] : null, 'account_balance' => $account_balance, 'pending_payments_total' => $pending_payments_total, + 'foreign_credits' => $foreign_credits, 'stats' => $stats, ]); } diff --git a/04_Laravel/app/Providers/Filament/AdminPanelProvider.php b/04_Laravel/app/Providers/Filament/AdminPanelProvider.php index ebdaa5f..5fc378e 100644 --- a/04_Laravel/app/Providers/Filament/AdminPanelProvider.php +++ b/04_Laravel/app/Providers/Filament/AdminPanelProvider.php @@ -76,6 +76,7 @@ class AdminPanelProvider extends PanelProvider ->widgets([ \App\Filament\Widgets\RecentShipmentsWidget::class, \App\Filament\Widgets\ExchangeRateWidget::class, + \App\Filament\Widgets\UnsettledCreditsWidget::class, ]) ->middleware([ EncryptCookies::class, diff --git a/04_Laravel/app/Services/CustomerCreditService.php b/04_Laravel/app/Services/CustomerCreditService.php index 0b4dd7c..30c5cd8 100644 --- a/04_Laravel/app/Services/CustomerCreditService.php +++ b/04_Laravel/app/Services/CustomerCreditService.php @@ -86,6 +86,15 @@ class CustomerCreditService ) { $credit = CustomerCredit::lockForUpdate()->findOrFail($credit->id); + // بررسی مجدد مانده بعد از lock + $currentBalance = (float) $credit->amount - (float) $credit->settled_amount; + if ($amountForeign > $currentBalance + 0.005) { + throw new RuntimeException( + 'مانده بدهی تغییر کرده. مانده فعلی: ' + . number_format($currentBalance, 2) . ' ' . $credit->currency_code + ); + } + $settlementAmountIrr = round($amountForeign * $exchangeRate, 2); $walletTransactionId = null; diff --git a/04_Laravel/resources/views/filament/widgets/unsettled-credits.blade.php b/04_Laravel/resources/views/filament/widgets/unsettled-credits.blade.php new file mode 100644 index 0000000..464c87a --- /dev/null +++ b/04_Laravel/resources/views/filament/widgets/unsettled-credits.blade.php @@ -0,0 +1,109 @@ +{{-- + IFNEX — Unsettled foreign currency credits dashboard widget. + Shows: currency summary badges + top 5 debtors table. +--}} + +
+ + {{-- ──────── Header ──────── --}} +
+
+ + + +
+

بدهی‌های ارزی تسویه‌نشده

+ + {{ ifnex_persian_digits(number_format($totalDebtors)) }} مشتری بدهکار، + {{ ifnex_persian_digits(number_format($totalOpenCredits)) }} اعتبار باز + +
+
+ + مشاهده همه ← + +
+ + @if(!$hasDebts) + {{-- Empty state --}} +
+
+ +
+

+ ✅ هیچ بدهی ارزی تسویه‌نشده‌ای وجود ندارد +

+
+ @else + {{-- ──────── Currency Summary ──────── --}} +
+
+ @foreach($currencySummary as $cs) + + {{ $cs['currency_code'] }}: + {{ number_format($cs['total_balance'], 2) }} + + ({{ ifnex_persian_digits(number_format($cs['credit_count'])) }} رکورد) + + + @endforeach +
+
+ + {{-- ──────── Top Debtors Table ──────── --}} +
+ + + + + + + + + + + + + @foreach($topDebtors as $debtor) + + + + + + + + + @endforeach + +
مشتریموبایلارزهاتعداد اعتبارآخرین اعطااقدام
+ + {{ $debtor->user_name }} + + + {{ $debtor->user_phone ?: '—' }} + + @php $currencies = explode(',', $debtor->currencies); @endphp + @foreach($currencies as $cur) + + {{ $cur }} + + @endforeach + + + {{ ifnex_persian_digits(number_format($debtor->credit_count)) }} + + + {{ $debtor->latest_grant ? \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($debtor->latest_grant))->format('Y/m/d') : '—' }} + + + وضعیت مالی + +
+
+ @endif +
+
\ No newline at end of file