diff --git a/03_WordPress/wp-content/themes/ifnex/functions.php b/03_WordPress/wp-content/themes/ifnex/functions.php index 4d9e8c0..bc6d5f9 100644 --- a/03_WordPress/wp-content/themes/ifnex/functions.php +++ b/03_WordPress/wp-content/themes/ifnex/functions.php @@ -250,4 +250,53 @@ function ifnex_disable_emojis() { remove_action('wp_head', 'print_emoji_detection_script', 7); remove_action('wp_print_styles', 'print_emoji_styles'); } -add_action('init', 'ifnex_disable_emojis'); \ No newline at end of file +add_action('init', 'ifnex_disable_emojis'); + +// ─── ریدایرکت پس از لاگین به پنل مشتری IFNEX ────────── +// مشتریان بعد از لاگین به /my-account/ می‌روند (به‌جای پیشخوان پیش‌فرض یا profile.php). +// مدیر سایت (administrator) همچنان به wp-admin می‌رود. +add_filter('login_redirect', function ($redirect_to, $request, $user) { + if (!($user instanceof \WP_User)) { + return $redirect_to; + } + + // مدیر سایت → رفتار پیش‌فرض (wp-admin) + if (in_array('administrator', (array) $user->roles, true)) { + return $redirect_to; + } + + // سایر کاربران (مشتریان IFNEX) → پنل مشتری + return home_url('/my-account/'); +}, 10, 3); + +// وقتی مشتری لاگین‌شده مستقیم wp-admin را باز می‌کند → به /my-account/ ریدایرکت کن +add_action('admin_init', function () { + $user = wp_get_current_user(); + if ($user && $user->exists() && !in_array('administrator', (array) $user->roles, true)) { + // فقط اگه درخواست AJAX نباشد + if (!(defined('DOING_AJAX') && DOING_AJAX) && !(defined('REST_REQUEST') && REST_REQUEST)) { + wp_safe_redirect(home_url('/my-account/')); + exit; + } + } +}); + +// ─── صفحه ورود سفارشی (به‌جای wp-login.php پیش‌فرض) ─── +// قالب template-login.php روی برگه‌ای با slug "login" فعال شود. +// پس از ساخت برگه، همه‌ی لینک‌های «ورود» به /login/ هدایت می‌شوند. +add_filter('login_url', function ($login_url, $redirect, $force_reauth) { + $login_page = get_page_by_path('login'); + if ($login_page) { + return get_permalink($login_page->ID); + } + return $login_url; // اگه برگه هنوز ساخته نشده، wp-login.php بمونه +}, 10, 3); + +// هنگام شکست لاگین، به برگه ورود سفارشی با پارامتر خطا برگرد +add_action('wp_login_failed', function () { + $login_page = get_page_by_path('login'); + $redirect_to = $login_page ? get_permalink($login_page->ID) : wp_login_url(); + wp_safe_redirect(add_query_arg('login', 'failed', $redirect_to)); + exit; +}); + diff --git a/04_Laravel/app/Console/Commands/UpdateExchangeRates.php b/04_Laravel/app/Console/Commands/UpdateExchangeRates.php index 2d5cceb..0d0993a 100644 --- a/04_Laravel/app/Console/Commands/UpdateExchangeRates.php +++ b/04_Laravel/app/Console/Commands/UpdateExchangeRates.php @@ -11,7 +11,7 @@ class UpdateExchangeRates extends Command { protected $signature = 'ifnex:update-rates {--source=ecb : Data source for exchange rates (ecb or freecurrencyapi)}'; - protected $description = 'Update exchange rates from external APIs and record history'; + protected $description = 'Fetch exchange rates from external APIs and create PENDING proposals (awaiting manager approval)'; public function handle(): int { @@ -33,41 +33,43 @@ class UpdateExchangeRates extends Command $this->updateRate('aed_to_irr', $rates['aed_to_irr'], 'api', null, $source); $this->updateRate('usd_to_irr', $rates['usd_to_irr'], 'api', null, $source); - $this->info("✅ Exchange rates updated successfully:"); - $this->line(" AED/IRR: " . number_format($rates['aed_to_irr'])); - $this->line(" USD/IRR: " . number_format($rates['usd_to_irr'])); + $this->info("✅ Pending rate proposals created (awaiting manager approval in panel):"); + $this->line(" AED/IRR proposed: " . number_format($rates['aed_to_irr'])); + $this->line(" USD/IRR proposed: " . number_format($rates['usd_to_irr'])); + $this->line(" Live rate unchanged until manager approves."); } else { - $this->warn("⚠️ No rates were updated. Using fallback values."); - $this->setFallbackRates(); + $this->warn("⚠️ API fetch failed. No pending rate created. Live rate unchanged."); } return Command::SUCCESS; } + /** + * نرخ زنده (SystemSetting) دست‌نخورده می‌مونه. + * فقط یک رکورد «در انتظار تأیید» در تاریخچه ثبت می‌شه. + * مدیر صبح در پنل → تأیید می‌کنه → اون‌وقت روی نرخ زنده اعمال می‌شه. + */ private function updateRate(string $key, float $newRate, string $source, ?int $userId, ?string $apiSource = null): void { $setting = SystemSetting::where('key', $key)->first(); $oldRate = $setting ? (float) $setting->value : 0; - // Update در system_settings - SystemSetting::updateOrCreate( - ['key' => $key], - ['value' => (string) $newRate] - ); - - // ثبت در تاریخچه فقط اگر تغییر کرده باشد - if ($oldRate != $newRate && $oldRate > 0) { + if ($oldRate > 0 && abs($newRate - $oldRate) > 0.01) { $currencyFrom = str_starts_with($key, 'aed') ? 'AED' : 'USD'; - - ExchangeRateHistory::recordChange( - from: $currencyFrom, - to: 'IRR', - oldRate: $oldRate, - newRate: $newRate, - source: $source, - userId: $userId, - apiSource: $apiSource - ); + $changePercent = round((($newRate - $oldRate) / $oldRate) * 100, 4); + + ExchangeRateHistory::create([ + 'currency_from' => $currencyFrom, + 'currency_to' => 'IRR', + 'old_rate' => $oldRate, + 'new_rate' => $newRate, + 'change_percent' => $changePercent, + 'change_source' => $source, + 'changed_by' => $userId, + 'api_source' => $apiSource, + 'notes' => null, + 'status' => 'pending', + ]); } } @@ -83,7 +85,7 @@ class UpdateExchangeRates extends Command return [ 'aed_to_irr' => round($data['rates']['IRR'] ?? 455000, 2), - 'usd_to_irr' => round(($data['rates']['IRR'] ?? 425000) / 1.07, 2), // USD از AED محاسبه می‌شود + 'usd_to_irr' => round(($data['rates']['IRR'] ?? 425000) / 1.07, 2), ]; } @@ -109,11 +111,4 @@ class UpdateExchangeRates extends Command 'usd_to_irr' => round(($data['data']['IRR'] ?? 425000) / 1.07, 2), ]; } - - private function setFallbackRates(): void - { - $this->updateRate('aed_to_irr', 455000, 'api', null, 'fallback'); - $this->updateRate('usd_to_irr', 425000, 'api', null, 'fallback'); - $this->info("Fallback rates set: AED/IRR=455000, USD/IRR=425000"); - } } \ No newline at end of file diff --git a/04_Laravel/app/Enums/TransactionType.php b/04_Laravel/app/Enums/TransactionType.php index 1679acb..f6217e6 100644 --- a/04_Laravel/app/Enums/TransactionType.php +++ b/04_Laravel/app/Enums/TransactionType.php @@ -8,6 +8,7 @@ enum TransactionType: string case WITHDRAWAL = 'withdrawal'; // برداشت / کسر case ORDER_PAYMENT = 'order_payment'; // پرداخت سفارش case REFUND = 'refund'; // بازگشت وجه + case CREDIT_SETTLEMENT = 'credit_settlement'; // تسویه اعتبار چندارزی از کیف پول public function label(): string { @@ -16,6 +17,7 @@ enum TransactionType: string self::WITHDRAWAL => 'برداشت', self::ORDER_PAYMENT => 'پرداخت سفارش', self::REFUND => 'بازگشت وجه', + self::CREDIT_SETTLEMENT => 'تسویه اعتبار', }; } @@ -26,6 +28,7 @@ enum TransactionType: string self::WITHDRAWAL => 'danger', self::ORDER_PAYMENT => 'warning', self::REFUND => 'info', + self::CREDIT_SETTLEMENT => 'info', }; } } \ No newline at end of file diff --git a/04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php b/04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php new file mode 100644 index 0000000..e9e0927 --- /dev/null +++ b/04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php @@ -0,0 +1,28 @@ +user()?->hasAnyRole(['super_admin', 'admin']) ?? false; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/CustomerCreditResource.php b/04_Laravel/app/Filament/Resources/CustomerCreditResource.php index e71e863..ca61c4e 100644 --- a/04_Laravel/app/Filament/Resources/CustomerCreditResource.php +++ b/04_Laravel/app/Filament/Resources/CustomerCreditResource.php @@ -3,18 +3,28 @@ namespace App\Filament\Resources; use App\Filament\Resources\CustomerCreditResource\Pages; -use App\Models\User; +use App\Models\CustomerCredit; +use App\Services\CustomerCreditService; +use App\Services\ExchangeRateService; use Filament\Forms; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\DB; +use Filament\Tables\Actions\Action; +use Filament\Notifications\Notification; +use Illuminate\Support\Facades\Auth; +/** + * اعتبار چندارزی مشتری (بدهی به ارز خارجی + تسویه ریالی). + * - ایجاد: فقط super_admin (canCreate). + * - تسویه: super_admin/admin/staff (اکشن روی ردیف). + * - کیف پول = مرجع؛ تسویه از کیف پول یک WalletTransaction (type=credit_settlement) می‌سازد. + */ class CustomerCreditResource extends Resource { - protected static ?string $model = User::class; + protected static ?string $model = CustomerCredit::class; + protected static ?string $navigationIcon = 'heroicon-o-banknotes'; protected static ?string $navigationGroup = 'مالی'; protected static ?string $navigationLabel = 'اعتبار مشتریان'; @@ -24,68 +34,84 @@ class CustomerCreditResource extends Resource public static function form(Form $form): Form { + $isCreate = $form->getOperation() === 'create'; + return $form->schema([ - Forms\Components\Section::make('اطلاعات مشتری') - ->schema([ - Forms\Components\TextInput::make('name') - ->label('نام') - ->disabled(), - Forms\Components\TextInput::make('email') - ->label('ایمیل') - ->disabled(), - Forms\Components\TextInput::make('phone') - ->label('موبایل') - ->disabled(), - ])->columns(3), + Forms\Components\Section::make('اطلاعات اعتبار')->schema([ + Forms\Components\Select::make('user_id') + ->label('مشتری') + ->relationship('user', 'name', fn (\Illuminate\Database\Eloquent\Builder $query) => $query->role('customer')->orderBy('name')) + ->searchable() + ->preload() + ->required() + ->disabled(! $isCreate), - Forms\Components\Section::make('اطلاعات اعتبار') - ->schema([ - Forms\Components\TextInput::make('credit_limit') - ->label('سقف اعتبار (ریال)') - ->numeric() - ->default(0) - ->minValue(0) - ->required(), - Forms\Components\TextInput::make('credit_used') - ->label('مبلغ استفاده شده (ریال)') - ->numeric() - ->default(0) - ->disabled(), - Forms\Components\TextInput::make('available_credit') - ->label('اعتبار باقیمانده (ریال)') - ->numeric() - ->disabled() - ->dehydrated(false), - ])->columns(3), + Forms\Components\TextInput::make('currency_code') + ->label('کد ارز') + ->placeholder('EUR, USD, AED, CNY') + ->required() + ->maxLength(10) + ->dehydrateStateUsing(fn ($state) => strtoupper(trim($state))) + ->disabled(! $isCreate), - Forms\Components\Section::make('تاریخچه تراکنش‌ها') + Forms\Components\TextInput::make('amount') + ->label('مبلغ اعتبار (به ارز خارجی)') + ->numeric() + ->step(0.01) + ->minValue(0.01) + ->required() + ->disabled(! $isCreate), + + Forms\Components\TextInput::make('reference') + ->label('مرجع (مثلاً شماره AWB)') + ->maxLength(100), + + Forms\Components\Select::make('shipment_id') + ->label('مرسوله مرتبط (اختیاری)') + ->relationship('shipment', 'awb_no') + ->searchable() + ->preload(), + + Forms\Components\Textarea::make('reason') + ->label('دلیل/توضیحات') + ->rows(3) + ->columnSpanFull(), + ])->columns(2), + + // فقط در حالت view نمایش داده می‌شوند + Forms\Components\Section::make('وضعیت تسویه') ->schema([ - Forms\Components\Placeholder::make('transactions_history') - ->label('تراکنش‌های اخیر') - ->content(function ($record) { - if (!$record) return '-'; - - $transactions = DB::table('wallet_transactions') - ->where('user_id', $record->id) - ->orderBy('created_at', 'desc') - ->take(5) - ->get(); - - if ($transactions->isEmpty()) return 'تراکنشی ثبت نشده'; - - $html = '
'; - foreach ($transactions as $tx) { - $type = match($tx->type) { - 'credit_add' => '➕ افزایش اعتبار', - 'credit_use' => '➖ استفاده از اعتبار', - default => $tx->type, - }; - $html .= '
' . $type . ': ' . number_format($tx->amount) . ' ریال - ' . $tx->description . '
'; - } - $html .= '
'; - return $html; + Forms\Components\Placeholder::make('settled_amount_display') + ->label('تسویه‌شده') + ->content(fn ($record) => $record + ? number_format((float) $record->settled_amount, 2) . ' ' . $record->currency_code + : '—'), + + Forms\Components\Placeholder::make('balance_display') + ->label('مانده بدهی') + ->content(fn ($record) => $record + ? number_format($record->balance, 2) . ' ' . $record->currency_code + : '—'), + + Forms\Components\Placeholder::make('status_display') + ->label('وضعیت') + ->content(fn ($record) => match ($record?->status) { + 'open' => 'باز', + 'partially_settled' => 'نیمه‌تسویه', + 'settled' => 'تسویه‌شده', + default => '—', }), - ]), + + Forms\Components\Placeholder::make('granted_by_display') + ->label('اعطاکننده') + ->content(fn ($record) => $record?->grantedBy?->name ?? '—'), + + Forms\Components\Placeholder::make('granted_at_display') + ->label('تاریخ اعطا') + ->content(fn ($record) => $record?->granted_at?->format('Y/m/d H:i') ?? '—'), + ]) + ->columns(3) + ->hidden($isCreate), ]); } @@ -93,138 +119,167 @@ class CustomerCreditResource extends Resource { return $table ->columns([ - Tables\Columns\TextColumn::make('id') - ->label('شناسه') - ->sortable(), - Tables\Columns\TextColumn::make('name') - ->label('نام') + Tables\Columns\TextColumn::make('user.name') + ->label('مشتری') ->searchable() ->sortable(), - Tables\Columns\TextColumn::make('email') - ->label('ایمیل') - ->searchable() + + Tables\Columns\TextColumn::make('currency_code') + ->label('ارز') + ->badge() + ->color('info'), + + Tables\Columns\TextColumn::make('amount') + ->label('مبلغ') + ->numeric(2) ->sortable(), - Tables\Columns\TextColumn::make('phone') - ->label('موبایل') - ->searchable(), - Tables\Columns\TextColumn::make('credit_limit') - ->label('سقف اعتبار') - ->numeric() - ->formatStateUsing(fn ($state) => number_format($state) . ' ریال') - ->sortable(), - Tables\Columns\TextColumn::make('credit_used') - ->label('استفاده شده') - ->numeric() - ->formatStateUsing(fn ($state) => number_format($state) . ' ریال') - ->sortable(), - Tables\Columns\TextColumn::make('available_credit') - ->label('باقیمانده') - ->numeric() - ->formatStateUsing(fn ($state) => number_format($state) . ' ریال') - ->color(fn ($state) => $state > 0 ? 'success' : 'danger') + + 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('reference') + ->label('مرجع') + ->toggleable(), + + Tables\Columns\TextColumn::make('grantedBy.name') + ->label('اعطاکننده') + ->toggleable(), + + Tables\Columns\TextColumn::make('granted_at') + ->label('تاریخ اعطا') + ->dateTime('Y/m/d H:i') ->sortable(), ]) ->filters([ - Tables\Filters\SelectFilter::make('has_credit') - ->label('وضعیت اعتبار') + Tables\Filters\SelectFilter::make('status') + ->label('وضعیت') ->options([ - 'has_limit' => 'دارای سقف اعتبار', - 'no_limit' => 'بدون سقف اعتبار', - 'has_used' => 'استفاده شده', - 'has_available' => 'باقیمانده', - ]) - ->query(function ($query, $filter) { - $state = $filter->getState(); - if (is_array($state)) { - $state = $state['value'] ?? null; - } - match ($state) { - 'has_limit' => $query->where('credit_limit', '>', 0), - 'no_limit' => $query->where('credit_limit', 0), - 'has_used' => $query->where('credit_used', '>', 0), - 'has_available' => $query->whereRaw('credit_limit - credit_used > 0'), - default => null, - }; - }), + '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() + ), ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\Action::make('add_credit') - ->label('افزایش اعتبار') - ->icon('heroicon-o-plus-circle') + Tables\Actions\ViewAction::make(), + + Action::make('settle') + ->label('ثبت تسویه') + ->icon('heroicon-o-banknotes') ->color('success') + ->visible(fn ($record) => $record->isOpen() + && auth()->user()?->hasAnyRole(['super_admin', 'admin', 'staff'])) ->form([ - Forms\Components\TextInput::make('amount') - ->label('مبلغ (ریال)') + Forms\Components\Placeholder::make('balance_info') + ->label('مانده بدهی') + ->content(fn ($record) => $record + ? number_format($record->balance, 2) . ' ' . $record->currency_code + : '—'), + + Forms\Components\TextInput::make('amount_foreign') + ->label(fn ($record) => "مبلغ تسویه ({$record->currency_code})") ->numeric() + ->step(0.01) + ->minValue(0.01) ->required() - ->minValue(1), - Forms\Components\TextInput::make('description') - ->label('توضیحات') - ->required(), - ]) - ->action(function ($record, array $data): void { - DB::beginTransaction(); - try { - $record->credit_limit += $data['amount']; - $record->save(); + ->helperText(fn ($record) => 'حداکثر: ' + . number_format($record->balance, 2) . ' ' . $record->currency_code), - DB::table('wallet_transactions')->insert([ - 'user_id' => $record->id, - 'type' => 'credit_add', - 'amount' => $data['amount'], - 'description' => $data['description'], - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::commit(); - } catch (\Exception $e) { - DB::rollBack(); - throw $e; - } - }), - Tables\Actions\Action::make('reduce_credit') - ->label('کاهش اعتبار') - ->icon('heroicon-o-minus-circle') - ->color('danger') - ->form([ - Forms\Components\TextInput::make('amount') - ->label('مبلغ (ریال)') + Forms\Components\TextInput::make('exchange_rate') + ->label('نرخ روز (ریال به یک واحد ارز)') ->numeric() + ->step(0.0001) + ->minValue(0.0001) ->required() - ->minValue(1), - Forms\Components\TextInput::make('description') - ->label('توضیحات') + ->helperText(function ($record) { + $rate = app(ExchangeRateService::class) + ->getCurrentRate($record->currency_code, 'IRR'); + return $rate > 0 + ? 'نرخ فعلی: ' . number_format($rate) . ' ریال' + : 'نرخ فعلی ثبت نشده — دستی وارد کنید.'; + }), + + Forms\Components\DatePicker::make('rate_date') + ->label('تاریخ نرخ') + ->default(now()) ->required(), + + Forms\Components\Toggle::make('from_wallet') + ->label('کسر از کیف پول (ریال)') + ->default(true) + ->helperText('اگر خاموش باشد، ثبت به‌عنوان پرداخت خارجی (نقدی/کارت) بدون تأثیر روی کیف پول.'), + + Forms\Components\Textarea::make('reason') + ->label('دلیل/توضیحات') + ->rows(2), ]) - ->action(function ($record, array $data): void { - DB::beginTransaction(); + ->action(function ($record, array $data) { try { - $record->credit_limit -= $data['amount']; - $record->save(); + $settlement = app(CustomerCreditService::class)->settle( + $record, + (float) $data['amount_foreign'], + (float) $data['exchange_rate'], + $data['rate_date'], + (bool) $data['from_wallet'], + Auth::id(), + $data['reason'] ?? null, + ); - DB::table('wallet_transactions')->insert([ - 'user_id' => $record->id, - 'type' => 'credit_reduce', - 'amount' => $data['amount'], - 'description' => $data['description'], - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::commit(); - } catch (\Exception $e) { - DB::rollBack(); - throw $e; + Notification::make() + ->title('تسویه ثبت شد') + ->body(number_format($data['amount_foreign'], 2) + . ' ' . $record->currency_code + . ' → ' . number_format($settlement->settlement_amount_irr) . ' ریال') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title('خطا در تسویه') + ->body($e->getMessage()) + ->danger() + ->send(); } }), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->visible(fn () => auth()->user()?->hasRole('super_admin')), ]), - ]); + ]) + ->defaultSort('created_at', 'desc'); } public static function getRelations(): array @@ -236,12 +291,32 @@ class CustomerCreditResource extends Resource { return [ 'index' => Pages\ListCustomerCredits::route('/'), - 'edit' => Pages\EditCustomerCredit::route('/{record}/edit'), + 'create' => Pages\CreateCustomerCredit::route('/create'), + 'view' => Pages\ViewCustomerCredit::route('/{record}'), ]; } - public static function getNavigationBadge(): ?string + // ─── Authorization ──────────────────────────────── + + public static function canViewAny(): bool { - return static::getModel()::where('credit_limit', '>', 0)->count(); + return auth()->user()?->hasAnyRole(['super_admin', 'admin', 'staff']) ?? false; } -} + + public static function canCreate(): bool + { + // فقط super_admin می‌تواند اعتبار جدید بدهد + return auth()->user()?->hasRole('super_admin') ?? false; + } + + public static function canEdit($record): bool + { + // اعتبار بعد از ایجاد غیرقابل‌ویرایش است (فقط از طریق تسویه) + return false; + } + + public static function canDelete($record): bool + { + return auth()->user()?->hasRole('super_admin') ?? false; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/CreateCustomerCredit.php b/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/CreateCustomerCredit.php new file mode 100644 index 0000000..42c9df6 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/CreateCustomerCredit.php @@ -0,0 +1,25 @@ +id(); + $data['granted_at'] = now(); + $data['settled_amount'] = 0; + $data['status'] = 'open'; + + return $data; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/ViewCustomerCredit.php b/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/ViewCustomerCredit.php new file mode 100644 index 0000000..8aeb254 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/ViewCustomerCredit.php @@ -0,0 +1,11 @@ +label('توسط') ->relationship('changedBy', 'name') ->disabled(), + Forms\Components\TextInput::make('status') + ->label('وضعیت') + ->disabled(), + Forms\Components\Select::make('approved_by') + ->label('تأییدکننده') + ->relationship('approvedBy', 'name') + ->disabled(), + Forms\Components\TextInput::make('approved_at') + ->label('تاریخ تأیید') + ->disabled(), Forms\Components\Textarea::make('notes') ->label('یادداشت') ->disabled() @@ -73,35 +83,35 @@ class ExchangeRateHistoryResource extends Resource ->label('شناسه') ->sortable() ->toggleable(isToggledHiddenByDefault: true), - + Tables\Columns\TextColumn::make('currency_pair') ->label('جفت ارز') ->state(fn ($record) => "{$record->currency_from} → {$record->currency_to}") ->badge() ->color('info'), - + Tables\Columns\TextColumn::make('old_rate') ->label('نرخ قبلی') ->numeric(0) ->suffix(' ریال') ->color('gray'), - + Tables\Columns\TextColumn::make('new_rate') ->label('نرخ جدید') ->numeric(0) ->suffix(' ریال') ->weight('bold') ->color('primary'), - + Tables\Columns\TextColumn::make('change_percent') ->label('تغییر') - ->state(fn ($record) => $record->change_percent !== null - ? number_format($record->change_percent, 2) . '%' + ->state(fn ($record) => $record->change_percent !== null + ? number_format($record->change_percent, 2) . '%' : '—') ->badge() ->color(fn ($record) => match(true) { - $record->change_percent > 0 => 'danger', // افزایش قیمت = قرمز - $record->change_percent < 0 => 'success', // کاهش قیمت = سبز + $record->change_percent > 0 => 'danger', + $record->change_percent < 0 => 'success', default => 'gray', }) ->icon(fn ($record) => match(true) { @@ -109,27 +119,59 @@ class ExchangeRateHistoryResource extends Resource $record->change_percent < 0 => 'heroicon-m-arrow-trending-down', default => null, }), - + Tables\Columns\TextColumn::make('change_source') ->label('منبع') ->badge() ->formatStateUsing(fn ($state) => $state === 'api' ? 'API خودکار' : 'دستی') ->color(fn ($state) => $state === 'api' ? 'info' : 'warning') - ->icon(fn ($state) => $state === 'api' - ? 'heroicon-m-cog-6-tooth' + ->icon(fn ($state) => $state === 'api' + ? 'heroicon-m-cog-6-tooth' : 'heroicon-m-pencil-square'), - + + Tables\Columns\TextColumn::make('status') + ->label('وضعیت') + ->badge() + ->formatStateUsing(fn (string $state): string => match($state) { + 'pending' => 'در انتظار', + 'approved' => 'تأیید شده', + 'rejected' => 'رد شده', + 'superseded' => 'جای‌گذشته', + default => $state, + }) + ->color(fn (string $state): string => match($state) { + 'pending' => 'warning', + 'approved' => 'success', + 'rejected' => 'danger', + 'superseded' => 'gray', + default => 'gray', + }) + ->icon(fn (string $state): string => match($state) { + 'pending' => 'heroicon-o-clock', + 'approved' => 'heroicon-o-check-circle', + 'rejected' => 'heroicon-o-x-circle', + default => 'heroicon-o-minus-circle', + }), + Tables\Columns\TextColumn::make('changedBy.name') ->label('توسط') ->state(fn ($record) => $record->changedBy?->name ?? ($record->change_source === 'api' ? 'سیستم' : '—')) ->icon('heroicon-m-user'), - + Tables\Columns\TextColumn::make('created_at') ->label('تاریخ تغییر') ->dateTime('Y/m/d H:i') ->sortable(), ]) ->filters([ + Tables\Filters\SelectFilter::make('status') + ->label('وضعیت') + ->options([ + 'pending' => 'در انتظار', + 'approved' => 'تأیید شده', + 'rejected' => 'رد شده', + 'superseded' => 'جای‌گذشته', + ]), Tables\Filters\SelectFilter::make('change_source') ->label('منبع') ->options([ @@ -161,9 +203,41 @@ class ExchangeRateHistoryResource extends Resource ]) ->actions([ Tables\Actions\ViewAction::make()->label('جزئیات'), + Tables\Actions\Action::make('approve') + ->label('تأیید') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->modalHeading('تأیید نرخ پیشنهادی') + ->modalDescription(fn ($record) => "نرخ {$record->currency_from}→{$record->currency_to} از " . number_format($record->old_rate) . " به " . number_format($record->new_rate) . " ریال تغییر یابد؟ نرخ زنده پس از تأیید به‌روز می‌شود.") + ->modalSubmitActionLabel('بله، تأیید و اعمال کن') + ->visible(fn ($record) => $record->status === 'pending') + ->action(function ($record) { + try { + app(ExchangeRateService::class)->approveRate($record, Auth::id()); + Notification::make()->title('نرخ تأیید و روی نرخ زنده اعمال شد')->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('خطا')->body($e->getMessage())->danger()->send(); + } + }), + Tables\Actions\Action::make('reject') + ->label('رد') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('رد نرخ پیشنهادی') + ->modalDescription('نرخ رد می‌شود. نرخ زنده دست‌نخورده باقی می‌ماند.') + ->visible(fn ($record) => $record->status === 'pending') + ->action(function ($record) { + try { + app(ExchangeRateService::class)->rejectRate($record, Auth::id()); + Notification::make()->title('نرخ رد شد')->warning()->send(); + } catch (\Throwable $e) { + Notification::make()->title('خطا')->body($e->getMessage())->danger()->send(); + } + }), ]) ->headerActions([ - // 🎯 دکمه اصلی: تنظیم نرخ جدید به صورت دستی Action::make('setNewRate') ->label('تنظیم نرخ جدید') ->icon('heroicon-o-pencil-square') @@ -177,25 +251,25 @@ class ExchangeRateHistoryResource extends Resource 'USD_IRR' => 'دلار به ریال (USD → IRR)', ]) ->default('AED_IRR'), - + Forms\Components\TextInput::make('new_rate') ->label('نرخ جدید (ریال)') ->required() ->numeric() ->minValue(1) ->prefix('ریال') - ->helperText(fn (callable $get) => + ->helperText(fn (callable $get) => 'نرخ فعلی: ' . number_format(self::getCurrentRateForPair($get('currency_pair'))) . ' ریال' ), - + Forms\Components\Textarea::make('notes') - ->label('یادداشت (اختیاری)') + ->label('یادداشت (اختیارر)') ->placeholder('دلیل تغییر نرخ...') ->maxLength(500), ]) ->action(function (array $data) { [$from, $to] = explode('_', $data['currency_pair']); - + $service = app(ExchangeRateService::class); $service->setRate( from: $from, @@ -204,28 +278,27 @@ class ExchangeRateHistoryResource extends Resource userId: Auth::id(), notes: $data['notes'] ?? null ); - + Notification::make() ->title('نرخ ارز با موفقیت به‌روز شد') ->body("نرخ {$from}/{$to} به " . number_format($data['new_rate']) . " ریال تغییر یافت") ->success() ->send(); }), - - // 🔄 دکمه آپدیت از API + Action::make('refreshFromApi') ->label('آپدیت از API') ->icon('heroicon-o-arrow-path') ->color('info') ->requiresConfirmation() ->modalHeading('آپدیت نرخ از API') - ->modalDescription('آیا مطمئن هستید که می‌خواهید نرخ ارز را از API به‌روزرسانی کنید؟') + ->modalDescription('این عمل یک نرخ پیشنهادی «در انتظار تأیید» می‌سازد. نرخ زنده تغییر نمی‌کند تا تأیید کنید.') ->action(function () { try { \Artisan::call('ifnex:update-rates'); - + Notification::make() - ->title('نرخ ارز از API به‌روزرسانی شد') + ->title('نرخ پیشنهادی API ساخته شد — منتظر تأیید شما') ->success() ->send(); } catch (\Throwable $e) { @@ -239,10 +312,9 @@ class ExchangeRateHistoryResource extends Resource ]) ->bulkActions([]) ->defaultSort('created_at', 'desc') - ->poll('60s'); // هر ۶۰ ثانیه خودکار refresh می‌شود + ->poll('60s'); } - // متد کمکی برای نمایش نرخ فعلی در فرم protected static function getCurrentRateForPair(?string $pair): float { if (!$pair) return 0; @@ -263,7 +335,6 @@ class ExchangeRateHistoryResource extends Resource ]; } - // فقط admin و super_admin دسترسی دارند public static function canViewAny(): bool { $user = Auth::user(); @@ -272,7 +343,7 @@ class ExchangeRateHistoryResource extends Resource public static function canCreate(): bool { - return false; // ایجاد دستی از فرم ممکن نیست، فقط از Action + return false; } public static function canEdit($record): bool @@ -284,4 +355,15 @@ class ExchangeRateHistoryResource extends Resource { return false; } + + public static function getNavigationBadge(): ?string + { + $count = app(ExchangeRateService::class)->getPendingCount(); + return $count > 0 ? (string) $count : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'warning'; + } } \ No newline at end of file diff --git a/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php b/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php index 253e17f..88d0337 100644 --- a/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php +++ b/04_Laravel/app/Filament/Widgets/ExchangeRateWidget.php @@ -11,7 +11,7 @@ class ExchangeRateWidget extends BaseWidget { protected static ?int $sort = 2; protected static bool $isLazy = false; - + protected int | string | array $columnSpan = 'full'; protected function getStats(): array @@ -19,17 +19,16 @@ class ExchangeRateWidget extends BaseWidget $service = app(ExchangeRateService::class); $aedInfo = $service->getLatestRateInfo('AED', 'IRR'); $usdInfo = $service->getLatestRateInfo('USD', 'IRR'); - + $aedChangePercent = $aedInfo['change_percent']; $usdChangePercent = $usdInfo['change_percent']; - - // ✅ استفاده از متد جدید getRateChart (بدون scope) + $aedChart = $service->getRateChart('AED', 'IRR', 7); $usdChart = $service->getRateChart('USD', 'IRR', 7); return [ Stat::make('نرخ درهم (AED)', number_format($aedInfo['current_rate']) . ' ریال') - ->description($aedChangePercent !== null + ->description($aedChangePercent !== null ? 'تغییر: ' . number_format($aedChangePercent, 2) . '% — توسط ' . ($aedInfo['changed_by'] ?? 'سیستم') : 'آخرین تغییر: ' . ($aedInfo['last_updated']?->format('Y/m/d H:i') ?? '—')) ->descriptionIcon( @@ -41,9 +40,9 @@ class ExchangeRateWidget extends BaseWidget ($aedChangePercent < 0 ? 'success' : 'gray') ) ->chart(!empty($aedChart) ? $aedChart : [0]), - + Stat::make('نرخ دلار (USD)', number_format($usdInfo['current_rate']) . ' ریال') - ->description($usdChangePercent !== null + ->description($usdChangePercent !== null ? 'تغییر: ' . number_format($usdChangePercent, 2) . '% — توسط ' . ($usdInfo['changed_by'] ?? 'سیستم') : 'آخرین تغییر: ' . ($usdInfo['last_updated']?->format('Y/m/d H:i') ?? '—')) ->descriptionIcon( @@ -55,13 +54,18 @@ class ExchangeRateWidget extends BaseWidget ($usdChangePercent < 0 ? 'success' : 'gray') ) ->chart(!empty($usdChart) ? $usdChart : [0]), - - Stat::make('تغییرات امروز', + + Stat::make('تغییرات امروز', ExchangeRateHistory::whereDate('created_at', today())->count() ) ->description('تغییرات نرخ ثبت‌شده در امروز') ->descriptionIcon('heroicon-m-arrow-path') ->color('info'), + + Stat::make('در انتظار تأیید', $service->getPendingCount()) + ->description('نرخ‌های پیشنهادی API — منتظر تأیید مدیر') + ->descriptionIcon('heroicon-m-clock') + ->color('warning'), ]; } } \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Api/CustomerFinancialController.php b/04_Laravel/app/Http/Controllers/Api/CustomerFinancialController.php index 6ce93cc..e639f7c 100644 --- a/04_Laravel/app/Http/Controllers/Api/CustomerFinancialController.php +++ b/04_Laravel/app/Http/Controllers/Api/CustomerFinancialController.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Models\Shipment; use App\Models\WalletTransaction; use App\Enums\ShipmentStatus; +use App\Services\CustomerCreditService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -60,16 +61,8 @@ class CustomerFinancialController extends Controller ->orderBy('created_at', 'desc') ->get(); - // محاسبه بدهی به هر ارز - $debts_by_currency = $pending_orders->groupBy(function ($order) { - return $order->fromCountry?->iso_code ?? 'unknown'; - })->map(function ($orders, $currency) { - return [ - 'currency' => $currency, - 'total' => $orders->sum('total_fee'), - 'count' => $orders->count(), - ]; - }); + // بدهی‌های چندارزی واقعی (از customer_credits) — به ارز خارجی، نه معادل ریالی + $foreign_credits = app(CustomerCreditService::class)->getCustomerDebtsByCurrency($customer); // دریافت تراکنش‌های اخیر $recent_transactions = WalletTransaction::whereHas('wallet', function ($q) use ($customer) { @@ -106,14 +99,15 @@ class CustomerFinancialController extends Controller 'created_at' => $order->created_at->format('Y-m-d H:i'), ]); - // محاسبه کل بدهی - $total_debt = $pending_orders->sum('total_fee'); - + // تعهدات ریالی (سفارشات تأییدشده‌ی پرداخت‌نشده) + $total_pending_irr = $pending_orders->sum('total_fee'); + // موجودی کیف پول $wallet_balance = $customer->wallet ? $customer->wallet->balance : 0; - - // مانده حساب - $account_balance = $wallet_balance - $total_debt; + + // مانده حساب = کیف پول منهای تعهدات ریالی + // بدهی‌های چندارزی (foreign_credits) جداگانه تسویه می‌شوند و از کیف پول کسر نمی‌شوند. + $account_balance = $wallet_balance - $total_pending_irr; return response()->json([ 'success' => true, @@ -125,10 +119,10 @@ class CustomerFinancialController extends Controller ], 'financial' => [ 'wallet_balance' => $wallet_balance, - 'total_debt' => $total_debt, 'account_balance' => $account_balance, - 'debts_by_currency' => array_values($debts_by_currency->toArray()), + 'total_pending_irr' => $total_pending_irr, 'pending_orders_count' => $pending_orders->count(), + 'foreign_credits' => $foreign_credits, ], 'recent_transactions' => $recent_transactions, 'recent_orders' => $recent_orders, diff --git a/04_Laravel/app/Models/CreditSettlement.php b/04_Laravel/app/Models/CreditSettlement.php new file mode 100644 index 0000000..cddd0f5 --- /dev/null +++ b/04_Laravel/app/Models/CreditSettlement.php @@ -0,0 +1,52 @@ + 'decimal:2', + 'exchange_rate' => 'decimal:4', + 'settlement_amount_irr' => 'decimal:2', + 'rate_date' => 'date', + 'settled_at' => 'datetime', + ]; + + // ─── Relationships ─────────────────────────────── + + public function credit(): BelongsTo + { + return $this->belongsTo(CustomerCredit::class, 'customer_credit_id'); + } + + public function walletTransaction(): BelongsTo + { + return $this->belongsTo(WalletTransaction::class); + } + + public function settledBy(): BelongsTo + { + return $this->belongsTo(User::class, 'settled_by'); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Models/CustomerCredit.php b/04_Laravel/app/Models/CustomerCredit.php new file mode 100644 index 0000000..82c53e5 --- /dev/null +++ b/04_Laravel/app/Models/CustomerCredit.php @@ -0,0 +1,92 @@ + 'decimal:2', + 'settled_amount' => 'decimal:2', + 'granted_at' => 'datetime', + ]; + + // ─── Relationships ─────────────────────────────── + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } + + public function grantedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'granted_by'); + } + + public function settlements(): HasMany + { + return $this->hasMany(CreditSettlement::class)->orderBy('settled_at', 'desc'); + } + + // ─── Helpers ───────────────────────────────────── + + /** + * مانده بدهی به ارز خارجی = amount - settled_amount + */ + public function getBalanceAttribute(): float + { + return (float) $this->amount - (float) $this->settled_amount; + } + + public function isOpen(): bool + { + return in_array($this->status, ['open', 'partially_settled'], true); + } + + public function isSettled(): bool + { + return $this->status === 'settled'; + } + + /** + * بازمحاسبه status بر اساس settled_amount. + */ + public function recomputeStatus(): void + { + $balance = $this->balance; + + if ($balance <= 0.005) { + $this->status = 'settled'; + } elseif ((float) $this->settled_amount > 0) { + $this->status = 'partially_settled'; + } else { + $this->status = 'open'; + } + + $this->save(); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Models/ExchangeRateHistory.php b/04_Laravel/app/Models/ExchangeRateHistory.php index 902eafb..e1eb60d 100644 --- a/04_Laravel/app/Models/ExchangeRateHistory.php +++ b/04_Laravel/app/Models/ExchangeRateHistory.php @@ -16,7 +16,10 @@ class ExchangeRateHistory extends Model 'new_rate', 'change_percent', 'change_source', + 'status', 'changed_by', + 'approved_by', + 'approved_at', 'api_source', 'notes', ]; @@ -25,10 +28,28 @@ class ExchangeRateHistory extends Model 'old_rate' => 'decimal:2', 'new_rate' => 'decimal:2', 'change_percent' => 'decimal:4', + 'approved_at' => 'datetime', ]; public function changedBy(): BelongsTo { return $this->belongsTo(User::class, 'changed_by'); } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + // ─── Helpers ─────────────────────────────────── + + public function isPending(): bool + { + return $this->status === 'pending'; + } + + public function isApproved(): bool + { + return $this->status === 'approved'; + } } \ No newline at end of file diff --git a/04_Laravel/app/Models/Shipment.php b/04_Laravel/app/Models/Shipment.php index 14ac880..50e635e 100644 --- a/04_Laravel/app/Models/Shipment.php +++ b/04_Laravel/app/Models/Shipment.php @@ -148,7 +148,7 @@ class Shipment extends Model ->orderBy('created_at', 'desc'); } - /** + /** * تاریخچه تغییرات وضعیت مرسوله */ public function statusHistories(): HasMany @@ -157,6 +157,14 @@ class Shipment extends Model ->orderBy('created_at', 'desc'); } + /** + * اعتبارها (بدهی‌های چندارزی) مرتبط با این مرسوله + */ + public function credits(): HasMany + { + return $this->hasMany(CustomerCredit::class); + } + // === Scopes === public function scopeByAwb($query, string $awbNo) diff --git a/04_Laravel/app/Models/User.php b/04_Laravel/app/Models/User.php index 66f7be3..9c409b3 100644 --- a/04_Laravel/app/Models/User.php +++ b/04_Laravel/app/Models/User.php @@ -41,11 +41,19 @@ class User extends Authenticatable // ─── Relationships ─────────────────────────────── - public function wallet() + public function wallet() { return $this->hasOne(Wallet::class); } + /** + * اعتبارها (بدهی‌های چندارزی) کاربر + */ + public function credits() + { + return $this->hasMany(CustomerCredit::class)->orderBy('created_at', 'desc'); + } + public function notifications() { return $this->morphMany(DatabaseNotification::class, 'notifiable')->orderByDesc('created_at'); diff --git a/04_Laravel/app/Policies/CustomerCreditPolicy.php b/04_Laravel/app/Policies/CustomerCreditPolicy.php new file mode 100644 index 0000000..ba04245 --- /dev/null +++ b/04_Laravel/app/Policies/CustomerCreditPolicy.php @@ -0,0 +1,38 @@ +hasAnyRole(['super_admin', 'admin', 'staff']); + } + + public function view(User $user, CustomerCredit $credit): bool + { + return $user->hasAnyRole(['super_admin', 'admin', 'staff']); + } + + public function create(User $user): bool + { + return $user->hasRole('super_admin'); + } + + public function update(User $user, CustomerCredit $credit): bool + { + return $user->hasRole('super_admin'); + } + + public function delete(User $user, CustomerCredit $credit): bool + { + return $user->hasRole('super_admin'); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Services/CustomerCreditService.php b/04_Laravel/app/Services/CustomerCreditService.php new file mode 100644 index 0000000..0b4dd7c --- /dev/null +++ b/04_Laravel/app/Services/CustomerCreditService.php @@ -0,0 +1,177 @@ + $customer->id, + 'currency_code' => strtoupper(trim($currencyCode)), + 'amount' => $amount, + 'settled_amount' => 0, + 'status' => 'open', + 'reason' => $reason, + 'reference' => $reference, + 'shipment_id' => $shipmentId, + 'granted_by' => $grantedBy, + 'granted_at' => now(), + ]); + }); + } + + /** + * ثبت تسویه (پرداخت ریالی) برای بخشی از یک اعتبار. + * + * @param bool $fromWallet اگه true، مبلغ ریالی از کیف پول کسر می‌شه. + */ + public function settle( + CustomerCredit $credit, + float $amountForeign, + float $exchangeRate, + string $rateDate, + bool $fromWallet, + int $settledBy, + ?string $reason = null, + ?string $reference = null + ): CreditSettlement { + if ($amountForeign <= 0) { + throw new RuntimeException('مبلغ تسویه باید مثبت باشد.'); + } + if ($exchangeRate <= 0) { + throw new RuntimeException('نرخ ارز باید مثبت باشد.'); + } + if (! $credit->isOpen()) { + throw new RuntimeException('این اعتبار قابل تسویه نیست (settled یا بسته).'); + } + if ($amountForeign > $credit->balance + 0.005) { + throw new RuntimeException('مبلغ تسویه از مانده بدهی بیشتر است. مانده: ' + . number_format($credit->balance, 2) . ' ' . $credit->currency_code); + } + + return DB::transaction(function () use ( + $credit, $amountForeign, $exchangeRate, $rateDate, $fromWallet, $settledBy, $reason, $reference + ) { + $credit = CustomerCredit::lockForUpdate()->findOrFail($credit->id); + + $settlementAmountIrr = round($amountForeign * $exchangeRate, 2); + + $walletTransactionId = null; + + if ($fromWallet) { + $wallet = $credit->user->wallet; + if (! $wallet) { + throw new RuntimeException('کاربر کیف پول ندارد.'); + } + if ($wallet->isFrozen()) { + throw new RuntimeException('کیف پول کاربر مسدود است.'); + } + if (! $wallet->hasSufficientBalance($settlementAmountIrr)) { + throw new RuntimeException('موجودی کیف پول کافی نیست. نیاز: ' + . number_format($settlementAmountIrr) . ' ریال'); + } + + $wt = WalletTransaction::create([ + 'wallet_id' => $wallet->id, + 'amount' => -$settlementAmountIrr, + 'balance_before' => $wallet->balance, + 'balance_after' => $wallet->balance - $settlementAmountIrr, + 'type' => TransactionType::CREDIT_SETTLEMENT, + 'status' => TransactionStatus::COMPLETED, + 'gateway' => PaymentGateway::WALLET, + 'description' => "تسویه اعتبار {$credit->currency_code} {$amountForeign} (credit #{$credit->id})", + 'transactionable_type' => CustomerCredit::class, + 'transactionable_id' => $credit->id, + 'created_by' => $settledBy, + ]); + + $wallet->update([ + 'balance' => $wallet->balance - $settlementAmountIrr, + 'total_withdrawn' => $wallet->total_withdrawn + $settlementAmountIrr, + ]); + + $walletTransactionId = $wt->id; + } + + $settlement = CreditSettlement::create([ + 'customer_credit_id' => $credit->id, + 'settled_amount_foreign' => $amountForeign, + 'currency_code' => $credit->currency_code, + 'settlement_currency' => 'IRR', + 'exchange_rate' => $exchangeRate, + 'rate_date' => $rateDate, + 'settlement_amount_irr' => $settlementAmountIrr, + 'wallet_transaction_id' => $walletTransactionId, + 'reason' => $reason, + 'reference' => $reference, + 'settled_by' => $settledBy, + 'settled_at' => now(), + ]); + + $credit->settled_amount = (float) $credit->settled_amount + $amountForeign; + $credit->recomputeStatus(); + + return $settlement; + }); + } + + /** + * بدهی‌های باز مشتری به تفکیک ارز. + * + * @return array + */ + public function getCustomerDebtsByCurrency(User $customer): array + { + return CustomerCredit::where('user_id', $customer->id) + ->whereIn('status', ['open', 'partially_settled']) + ->select('currency_code') + ->selectRaw('SUM(amount) as total_amount') + ->selectRaw('SUM(settled_amount) as total_settled') + ->selectRaw('SUM(amount - settled_amount) as balance') + ->selectRaw('COUNT(*) as count') + ->groupBy('currency_code') + ->get() + ->map(function ($row) { + return [ + 'currency_code' => $row->currency_code, + 'total_amount' => (float) $row->total_amount, + 'total_settled' => (float) $row->total_settled, + 'balance' => (float) $row->balance, + 'count' => (int) $row->count, + ]; + }) + ->toArray(); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Services/ExchangeRateService.php b/04_Laravel/app/Services/ExchangeRateService.php index 12759f2..2c9892e 100644 --- a/04_Laravel/app/Services/ExchangeRateService.php +++ b/04_Laravel/app/Services/ExchangeRateService.php @@ -4,26 +4,28 @@ namespace App\Services; use App\Models\SystemSetting; use App\Models\ExchangeRateHistory; +use Illuminate\Support\Facades\DB; +use RuntimeException; class ExchangeRateService { /** - * دریافت نرخ فعلی + * دریافت نرخ فعلی (زنده = تأییدشده) */ public function getCurrentRate(string $from = 'AED', string $to = 'IRR'): float { $key = strtolower("{$from}_to_{$to}"); $setting = SystemSetting::where('key', $key)->first(); - + if (!$setting) { return config('ifnex.financial_defaults.aed_to_irr', 455000); } - + return (float) $setting->value; } /** - * تنظیم نرخ جدید (توسط کاربر) + * تنظیم نرخ جدید به‌صورت دستی (توسط مدیر) → مستقیم اعمال می‌شه (status=approved) */ public function setRate( string $from, @@ -67,18 +69,17 @@ class ExchangeRateService } /** - * دریافت اطلاعات آخرین نرخ برای Widget - * ⚠️ بدون استفاده از scope - کوئری مستقیم + * دریافت اطلاعات آخرین نرخ (تأییدشده) برای Widget */ public function getLatestRateInfo(string $from = 'AED', string $to = 'IRR'): array { - // ✅ کوئری مستقیم بدون scope $latest = ExchangeRateHistory::where('currency_from', $from) ->where('currency_to', $to) + ->where('status', 'approved') // فقط تغییرات تأییدشده ->orderBy('created_at', 'desc') ->with('changedBy') ->first(); - + $currentRate = $this->getCurrentRate($from, $to); return [ @@ -92,12 +93,13 @@ class ExchangeRateService } /** - * دریافت آخرین N نرخ برای Chart + * دریافت آخرین N نرخ (تأییدشده) برای Chart */ public function getRateChart(string $from = 'AED', string $to = 'IRR', int $limit = 7): array { return ExchangeRateHistory::where('currency_from', $from) ->where('currency_to', $to) + ->where('status', 'approved') // فقط تغییرات تأییدشده ->orderBy('created_at', 'asc') ->limit($limit) ->pluck('new_rate') @@ -105,7 +107,7 @@ class ExchangeRateService } /** - * ثبت تغییر نرخ + * ثبت تغییر نرخ (داخلی). ورود دستی → status=approved (مستقیم اعمال). */ private function recordChange( string $from, @@ -123,15 +125,88 @@ class ExchangeRateService } return ExchangeRateHistory::create([ - 'currency_from' => $from, - 'currency_to' => $to, - 'old_rate' => $oldRate, - 'new_rate' => $newRate, + 'currency_from' => $from, + 'currency_to' => $to, + 'old_rate' => $oldRate, + 'new_rate' => $newRate, 'change_percent' => $changePercent, - 'change_source' => $source, - 'changed_by' => $userId, - 'api_source' => $apiSource, - 'notes' => $notes, + 'change_source' => $source, + 'changed_by' => $userId, + 'approved_by' => $userId, // ورود دستی = خود مدیر تأیید کرده + 'approved_at' => now(), + 'api_source' => $apiSource, + 'notes' => $notes, + 'status' => 'approved', // ورود دستی مستقیم اعمال می‌شه ]); } + + /** + * تأیید یک نرخ در انتظار توسط مدیر. + * نرخ روی SystemSetting (زنده) اعمال می‌شه و رکورد history تأیید می‌شه. + */ + public function approveRate(ExchangeRateHistory $history, int $userId): ExchangeRateHistory + { + if (! $history->isPending()) { + throw new RuntimeException('این رکورد در وضعیت قابل تأیید نیست.'); + } + + return DB::transaction(function () use ($history, $userId) { + $key = strtolower("{$history->currency_from}_to_{$history->currency_to}"); + + SystemSetting::updateOrCreate( + ['key' => $key], + [ + 'value' => (string) $history->new_rate, + 'description' => "{$history->currency_from} to {$history->currency_to} exchange rate", + 'updated_by' => $userId, + ] + ); + + $history->update([ + 'status' => 'approved', + 'approved_by' => $userId, + 'approved_at' => now(), + ]); + + return $history->fresh(); + }); + } + + /** + * رد یک نرخ در انتظار. نرخ زنده دست‌نخورده می‌مونه. + */ + public function rejectRate(ExchangeRateHistory $history, int $userId, ?string $reason = null): ExchangeRateHistory + { + if (! $history->isPending()) { + throw new RuntimeException('این رکورد در وضعیت قابل رد نیست.'); + } + + $history->update([ + 'status' => 'rejected', + 'approved_by' => $userId, + 'approved_at' => now(), + 'notes' => $reason ?: $history->notes, + ]); + + return $history->fresh(); + } + + /** + * لیست نرخ‌های در انتظار تأیید. + */ + public function getPendingRates(int $limit = 20) + { + return ExchangeRateHistory::where('status', 'pending') + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + } + + /** + * تعداد نرخ‌های در انتظار تأیید (برای badge). + */ + public function getPendingCount(): int + { + return ExchangeRateHistory::where('status', 'pending')->count(); + } } \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_09_30_000001_add_status_to_exchange_rate_history_table.php b/04_Laravel/database/migrations/2026_09_30_000001_add_status_to_exchange_rate_history_table.php new file mode 100644 index 0000000..c5c5983 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_30_000001_add_status_to_exchange_rate_history_table.php @@ -0,0 +1,44 @@ +enum('status', ['pending', 'approved', 'rejected', 'superseded']) + ->default('approved') + ->after('change_source') + ->comment('pending=در انتظار تأیید مدیر، approved=اعمال‌شده روی نرخ زنده'); + + $table->foreignId('approved_by')->nullable() + ->after('changed_by') + ->constrained('users') + ->nullOnDelete(); + + $table->timestamp('approved_at')->nullable()->after('approved_by'); + + $table->index('status', 'exr_history_status_idx'); + }); + } + + public function down(): void + { + Schema::table('exchange_rate_history', function (Blueprint $table) { + $table->dropIndex('exr_history_status_idx'); + $table->dropForeign(['approved_by']); + $table->dropColumn(['status', 'approved_by', 'approved_at']); + }); + } +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_09_30_100001_create_customer_credits_table.php b/04_Laravel/database/migrations/2026_09_30_100001_create_customer_credits_table.php new file mode 100644 index 0000000..eb7bd8f --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_30_100001_create_customer_credits_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('currency_code', 10)->comment('EUR, USD, AED, CNY, ...'); + + $table->decimal('amount', 15, 2)->comment('مبلغ کل اعتبار به ارز خارجی'); + $table->decimal('settled_amount', 15, 2)->default(0)->comment('چقدر تسویه شده (به همون ارز)'); + + $table->enum('status', ['open', 'partially_settled', 'settled'])->default('open'); + + $table->text('reason')->nullable(); + $table->string('reference', 100)->nullable()->comment('مثلاً شماره AWB'); + + $table->foreignId('shipment_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('granted_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('granted_at')->nullable(); + + $table->timestamps(); + + $table->index(['user_id', 'currency_code', 'status'], 'cc_user_cur_status_idx'); + $table->index('shipment_id', 'cc_shipment_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_credits'); + } +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_09_30_100002_create_credit_settlements_table.php b/04_Laravel/database/migrations/2026_09_30_100002_create_credit_settlements_table.php new file mode 100644 index 0000000..c0f7d1d --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_30_100002_create_credit_settlements_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('customer_credit_id')->constrained()->cascadeOnDelete(); + + $table->decimal('settled_amount_foreign', 15, 2); + $table->string('currency_code', 10)->comment('echo از credit'); + $table->string('settlement_currency', 10)->default('IRR'); + + $table->decimal('exchange_rate', 15, 4); + $table->date('rate_date')->comment('تاریخ نرخ'); + + $table->decimal('settlement_amount_irr', 15, 2); + + $table->foreignId('wallet_transaction_id')->nullable() + ->constrained('wallet_transactions')->nullOnDelete() + ->comment('اگه از کیف پول پرداخت شده'); + + $table->text('reason')->nullable(); + $table->string('reference', 100)->nullable(); + + $table->foreignId('settled_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('settled_at')->nullable(); + + $table->timestamps(); + + $table->index('customer_credit_id', 'cs_credit_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_settlements'); + } +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_09_30_100003_add_credit_settlement_to_wallet_transactions_type.php b/04_Laravel/database/migrations/2026_09_30_100003_add_credit_settlement_to_wallet_transactions_type.php new file mode 100644 index 0000000..f8f3817 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_30_100003_add_credit_settlement_to_wallet_transactions_type.php @@ -0,0 +1,50 @@ +getDriverName() === 'sqlite') { + return; + } + + DB::statement(" + ALTER TABLE `wallet_transactions` + MODIFY COLUMN `type` ENUM( + 'deposit', + 'withdrawal', + 'order_payment', + 'refund', + 'credit_settlement' + ) NOT NULL COMMENT 'نوع تراکنش' + "); + } + + public function down(): void + { + if (DB::connection()->getDriverName() === 'sqlite') { + return; + } + + DB::table('wallet_transactions') + ->where('type', 'credit_settlement') + ->update(['type' => 'withdrawal']); + + DB::statement(" + ALTER TABLE `wallet_transactions` + MODIFY COLUMN `type` ENUM( + 'deposit', + 'withdrawal', + 'order_payment', + 'refund' + ) NOT NULL COMMENT 'نوع تراکنش' + "); + } +}; \ No newline at end of file diff --git a/04_Laravel/resources/views/filament/pages/dashboard-widget-redesign.blade.php b/04_Laravel/resources/views/filament/pages/dashboard-widget-redesign.blade.php new file mode 100644 index 0000000..57488b2 --- /dev/null +++ b/04_Laravel/resources/views/filament/pages/dashboard-widget-redesign.blade.php @@ -0,0 +1,52 @@ + +
+ + + + طراحی مجدد ویجت‌های داشبورد — پیش‌رو (پس از تحویل) + + +
+

+ این بخش برای فاز بعد از تحویل در نظر گرفته شده است. ویجت‌های فعلی داشبورد عملکردی هستند + و درست کار می‌کنند، ولی طراحی ظاهری نهایی نشده است. + پس از تحویل پروژه به مشتری، ویجت‌های زیر بازطراحی خواهند شد. +

+
+
+ + + + + ویجت‌هایی که بازطراحی خواهند شد + + +
    +
  • + + DashboardInfoWidget — آمار کلی مرسولات +
  • +
  • + + ExchangeRateWidget — نرخ ارز + نمودار + تعداد در انتظار تأیید +
  • +
  • + + WalletStats — آمار کیف پول +
  • +
  • + + TransactionChartWidget — نمودار تراکنش‌ها +
  • +
  • + + FinanceOverviewWidget — نمای کلی مالی +
  • +
  • + + RecentTransactionsWidget — تراکنش‌های اخیر +
  • +
+
+
+
\ No newline at end of file