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