diff --git a/04_Laravel/app/Console/Commands/UpdateExchangeRates.php b/04_Laravel/app/Console/Commands/UpdateExchangeRates.php index 979f431..2d5cceb 100644 --- a/04_Laravel/app/Console/Commands/UpdateExchangeRates.php +++ b/04_Laravel/app/Console/Commands/UpdateExchangeRates.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\SystemSetting; +use App\Models\ExchangeRateHistory; use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; @@ -10,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'; + protected $description = 'Update exchange rates from external APIs and record history'; public function handle(): int { @@ -25,28 +26,54 @@ class UpdateExchangeRates extends Command }; } catch (\Throwable $e) { $this->error("Failed to fetch rates: {$e->getMessage()}"); - return Command::FAILURE; } if ($rates) { - SystemSetting::where('key', 'aed_to_irr')->update(['value' => (string) $rates['aed_to_irr']]); - SystemSetting::where('key', 'usd_to_irr')->update(['value' => (string) $rates['usd_to_irr']]); + $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: {$rates['aed_to_irr']}"); - $this->line(" USD/IRR: {$rates['usd_to_irr']}"); + $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'])); } else { - $this->warn("No rates were updated. Using fallback values."); + $this->warn("⚠️ No rates were updated. Using fallback values."); $this->setFallbackRates(); } return Command::SUCCESS; } + 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) { + $currencyFrom = str_starts_with($key, 'aed') ? 'AED' : 'USD'; + + ExchangeRateHistory::recordChange( + from: $currencyFrom, + to: 'IRR', + oldRate: $oldRate, + newRate: $newRate, + source: $source, + userId: $userId, + apiSource: $apiSource + ); + } + } + private function fetchFromEcb(): ?array { - $response = Http::get('https://api.exchangerate-api.com/v4/latest/AED'); + $response = Http::timeout(10)->get('https://api.exchangerate-api.com/v4/latest/AED'); if ($response->failed()) { return null; @@ -56,14 +83,14 @@ class UpdateExchangeRates extends Command return [ 'aed_to_irr' => round($data['rates']['IRR'] ?? 455000, 2), - 'usd_to_irr' => round($data['rates']['IRR'] ?? 425000, 2), + 'usd_to_irr' => round(($data['rates']['IRR'] ?? 425000) / 1.07, 2), // USD از AED محاسبه می‌شود ]; } private function fetchFromFreeCurrencyApi(): ?array { - $response = Http::get('https://free.currencyapi.com/api/v3/latest', [ - 'apikey' => config('ifnex.currency_api_key'), + $response = Http::timeout(10)->get('https://api.freecurrencyapi.com/v1/latest', [ + 'apikey' => config('ifnex.currency.api_key'), 'base_currency' => 'AED', ]); @@ -78,21 +105,15 @@ class UpdateExchangeRates extends Command } return [ - 'aed_to_irr' => round(($data['data']['IRR']['value'] ?? 455000) * 100, 2), - 'usd_to_irr' => round(($data['data']['IRR']['value'] ?? 425000) * 100, 2), + 'aed_to_irr' => round(($data['data']['IRR'] ?? 455000), 2), + 'usd_to_irr' => round(($data['data']['IRR'] ?? 425000) / 1.07, 2), ]; } private function setFallbackRates(): void { - SystemSetting::updateOrCreate( - ['key' => 'aed_to_irr'], - ['value' => '455000', 'type' => 'number', 'description' => 'AED to IRR exchange rate'] - ); - SystemSetting::updateOrCreate( - ['key' => 'usd_to_irr'], - ['value' => '425000', 'type' => 'number', 'description' => 'USD to IRR exchange rate'] - ); + $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/Filament/Resources/ExchangeRateHistoryResource.php b/04_Laravel/app/Filament/Resources/ExchangeRateHistoryResource.php new file mode 100644 index 0000000..0c68a5d --- /dev/null +++ b/04_Laravel/app/Filament/Resources/ExchangeRateHistoryResource.php @@ -0,0 +1,287 @@ +schema([ + Forms\Components\Section::make('جزئیات تغییر نرخ') + ->schema([ + Forms\Components\TextInput::make('currency_from') + ->label('ارز مبدأ') + ->disabled(), + Forms\Components\TextInput::make('currency_to') + ->label('ارز مقصد') + ->disabled(), + Forms\Components\TextInput::make('old_rate') + ->label('نرخ قبلی') + ->disabled() + ->suffix('ریال'), + Forms\Components\TextInput::make('new_rate') + ->label('نرخ جدید') + ->disabled() + ->suffix('ریال'), + Forms\Components\TextInput::make('change_percent') + ->label('درصد تغییر') + ->disabled() + ->suffix('%'), + Forms\Components\TextInput::make('change_source') + ->label('منبع تغییر') + ->disabled(), + Forms\Components\Select::make('changed_by') + ->label('توسط') + ->relationship('changedBy', 'name') + ->disabled(), + Forms\Components\Textarea::make('notes') + ->label('یادداشت') + ->disabled() + ->columnSpanFull(), + ])->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->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) . '%' + : '—') + ->badge() + ->color(fn ($record) => match(true) { + $record->change_percent > 0 => 'danger', // افزایش قیمت = قرمز + $record->change_percent < 0 => 'success', // کاهش قیمت = سبز + default => 'gray', + }) + ->icon(fn ($record) => match(true) { + $record->change_percent > 0 => 'heroicon-m-arrow-trending-up', + $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' + : 'heroicon-m-pencil-square'), + + 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('change_source') + ->label('منبع') + ->options([ + 'api' => 'API خودکار', + 'manual' => 'دستی', + ]), + Tables\Filters\SelectFilter::make('currency_pair') + ->label('جفت ارز') + ->options([ + 'AED_IRR' => 'AED → IRR', + 'USD_IRR' => 'USD → IRR', + ]) + ->query(function ($query, array $data) { + if (!$data['value']) return $query; + [$from, $to] = explode('_', $data['value']); + return $query->where('currency_from', $from) + ->where('currency_to', $to); + }), + Tables\Filters\Filter::make('created_at') + ->form([ + Forms\Components\DatePicker::make('from')->label('از تاریخ'), + Forms\Components\DatePicker::make('until')->label('تا تاریخ'), + ]) + ->query(function ($query, array $data) { + return $query + ->when($data['from'], fn ($q, $d) => $q->whereDate('created_at', '>=', $d)) + ->when($data['until'], fn ($q, $d) => $q->whereDate('created_at', '<=', $d)); + }), + ]) + ->actions([ + Tables\Actions\ViewAction::make()->label('جزئیات'), + ]) + ->headerActions([ + // 🎯 دکمه اصلی: تنظیم نرخ جدید به صورت دستی + Action::make('setNewRate') + ->label('تنظیم نرخ جدید') + ->icon('heroicon-o-pencil-square') + ->color('warning') + ->form([ + Forms\Components\Select::make('currency_pair') + ->label('جفت ارز') + ->required() + ->options([ + 'AED_IRR' => 'درهم به ریال (AED → IRR)', + 'USD_IRR' => 'دلار به ریال (USD → IRR)', + ]) + ->default('AED_IRR'), + + Forms\Components\TextInput::make('new_rate') + ->label('نرخ جدید (ریال)') + ->required() + ->numeric() + ->minValue(1) + ->prefix('ریال') + ->helperText(fn (callable $get) => + 'نرخ فعلی: ' . number_format(self::getCurrentRateForPair($get('currency_pair'))) . ' ریال' + ), + + Forms\Components\Textarea::make('notes') + ->label('یادداشت (اختیاری)') + ->placeholder('دلیل تغییر نرخ...') + ->maxLength(500), + ]) + ->action(function (array $data) { + [$from, $to] = explode('_', $data['currency_pair']); + + $service = app(ExchangeRateService::class); + $service->setRate( + from: $from, + to: $to, + newRate: (float) $data['new_rate'], + 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 به‌روزرسانی کنید؟') + ->action(function () { + try { + \Artisan::call('ifnex:update-rates'); + + Notification::make() + ->title('نرخ ارز از API به‌روزرسانی شد') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title('خطا در به‌روزرسانی') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->bulkActions([]) + ->defaultSort('created_at', 'desc') + ->poll('60s'); // هر ۶۰ ثانیه خودکار refresh می‌شود + } + + // متد کمکی برای نمایش نرخ فعلی در فرم + protected static function getCurrentRateForPair(?string $pair): float + { + if (!$pair) return 0; + [$from, $to] = explode('_', $pair); + return app(ExchangeRateService::class)->getCurrentRate($from, $to); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListExchangeRateHistories::route('/'), + 'view' => Pages\ViewExchangeRateHistory::route('/{record}'), + ]; + } + + // فقط admin و super_admin دسترسی دارند + public static function canViewAny(): bool + { + $user = Auth::user(); + return $user && $user->hasAnyRole(['super_admin', 'admin']); + } + + public static function canCreate(): bool + { + return false; // ایجاد دستی از فرم ممکن نیست، فقط از Action + } + + public static function canEdit($record): bool + { + return false; + } + + public static function canDelete($record): bool + { + return false; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/ExchangeRateHistoryResource/Pages/CreateExchangeRateHistory.php b/04_Laravel/app/Filament/Resources/ExchangeRateHistoryResource/Pages/CreateExchangeRateHistory.php new file mode 100644 index 0000000..ea65148 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/ExchangeRateHistoryResource/Pages/CreateExchangeRateHistory.php @@ -0,0 +1,12 @@ + 'decimal:2', + 'new_rate' => 'decimal:2', + 'change_percent' => 'decimal:4', + ]; + + public function changedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'changed_by'); + } + + // Helper برای محاسبه درصد تغییر + public static function calculateChangePercent($old, $new): ?float + { + if (!$old || $old == 0) return null; + return round((($new - $old) / $old) * 100, 4); + } + + // Helper برای ثبت تغییر + public static function recordChange( + string $from, + string $to, + float $oldRate, + float $newRate, + string $source = 'manual', + ?int $userId = null, + ?string $apiSource = null, + ?string $notes = null + ): self { + return self::create([ + 'currency_from' => $from, + 'currency_to' => $to, + 'old_rate' => $oldRate, + 'new_rate' => $newRate, + 'change_percent' => self::calculateChangePercent($oldRate, $newRate), + 'change_source' => $source, + 'changed_by' => $userId, + 'api_source' => $apiSource, + 'notes' => $notes, + ]); + } + + // Scope برای آخرین تغییر + public function scopeLatest($query, string $from = 'AED', string $to = 'IRR') + { + return $query->where('currency_from', $from) + ->where('currency_to', $to) + ->orderBy('created_at', 'desc'); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Services/ExchangeRateService.php b/04_Laravel/app/Services/ExchangeRateService.php new file mode 100644 index 0000000..a312551 --- /dev/null +++ b/04_Laravel/app/Services/ExchangeRateService.php @@ -0,0 +1,88 @@ +first(); + + if (!$setting) { + return config('ifnex.financial_defaults.aed_to_irr', 455000); + } + + return (float) $setting->value; + } + + /** + * تنظیم نرخ جدید (توسط کاربر) + */ + public function setRate( + string $from, + string $to, + float $newRate, + ?int $userId = null, + ?string $notes = null + ): ExchangeRateHistory { + $key = strtolower("{$from}_to_{$to}"); + $oldRate = $this->getCurrentRate($from, $to); + + // Update system_settings + SystemSetting::updateOrCreate( + ['key' => $key], + [ + 'value' => (string) $newRate, + 'type' => 'number', + 'description' => "{$from} to {$to} exchange rate", + ] + ); + + // ثبت در تاریخچه + return ExchangeRateHistory::recordChange( + from: $from, + to: $to, + oldRate: $oldRate, + newRate: $newRate, + source: 'manual', + userId: $userId, + notes: $notes + ); + } + + /** + * دریافت آخرین تغییرات + */ + public function getLatestChanges(int $limit = 10) + { + return ExchangeRateHistory::with('changedBy') + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + } + + /** + * دریافت اطلاعات آخرین نرخ برای Widget + */ + public function getLatestRateInfo(string $from = 'AED', string $to = 'IRR'): array + { + $latest = ExchangeRateHistory::latest($from, $to)->with('changedBy')->first(); + $currentRate = $this->getCurrentRate($from, $to); + + return [ + 'current_rate' => $currentRate, + 'last_change' => $latest, + 'last_updated' => $latest?->created_at, + 'changed_by' => $latest?->changedBy?->name, + 'source' => $latest?->change_source, + 'change_percent' => $latest?->change_percent, + ]; + } +} \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_08_09_231738_create_exchange_rate_history_table.php b/04_Laravel/database/migrations/2026_08_09_231738_create_exchange_rate_history_table.php new file mode 100644 index 0000000..403f7ad --- /dev/null +++ b/04_Laravel/database/migrations/2026_08_09_231738_create_exchange_rate_history_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('currency_from', 10)->default('AED'); + $table->string('currency_to', 10)->default('IRR'); + $table->decimal('old_rate', 15, 2)->nullable(); + $table->decimal('new_rate', 15, 2); + $table->decimal('change_percent', 8, 4)->nullable(); + $table->enum('change_source', ['api', 'manual'])->default('manual'); + $table->foreignId('changed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('api_source')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['currency_from', 'currency_to', 'created_at']); + $table->index('changed_by'); + }); + } + + public function down(): void + { + Schema::dropIfExists('exchange_rate_history'); + } +}; \ No newline at end of file