feat(finance): rate approval + multi-currency credit system (درخواست ۹)
- rate approval: API creates pending proposals, manager approves - customer_credits + credit_settlements tables - CustomerCreditService (grant/settle via wallet) - rebuilt CustomerCreditResource - CustomerFinancialController: real foreign-currency debt - WP login redirect + custom login page wiring
This commit is contained in:
parent
78036245ad
commit
3848f0b399
@ -250,4 +250,53 @@ function ifnex_disable_emojis() {
|
|||||||
remove_action('wp_head', 'print_emoji_detection_script', 7);
|
remove_action('wp_head', 'print_emoji_detection_script', 7);
|
||||||
remove_action('wp_print_styles', 'print_emoji_styles');
|
remove_action('wp_print_styles', 'print_emoji_styles');
|
||||||
}
|
}
|
||||||
add_action('init', 'ifnex_disable_emojis');
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class UpdateExchangeRates extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'ifnex:update-rates {--source=ecb : Data source for exchange rates (ecb or freecurrencyapi)}';
|
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
|
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('aed_to_irr', $rates['aed_to_irr'], 'api', null, $source);
|
||||||
$this->updateRate('usd_to_irr', $rates['usd_to_irr'], 'api', null, $source);
|
$this->updateRate('usd_to_irr', $rates['usd_to_irr'], 'api', null, $source);
|
||||||
|
|
||||||
$this->info("✅ Exchange rates updated successfully:");
|
$this->info("✅ Pending rate proposals created (awaiting manager approval in panel):");
|
||||||
$this->line(" AED/IRR: " . number_format($rates['aed_to_irr']));
|
$this->line(" AED/IRR proposed: " . number_format($rates['aed_to_irr']));
|
||||||
$this->line(" USD/IRR: " . number_format($rates['usd_to_irr']));
|
$this->line(" USD/IRR proposed: " . number_format($rates['usd_to_irr']));
|
||||||
|
$this->line(" Live rate unchanged until manager approves.");
|
||||||
} else {
|
} else {
|
||||||
$this->warn("⚠️ No rates were updated. Using fallback values.");
|
$this->warn("⚠️ API fetch failed. No pending rate created. Live rate unchanged.");
|
||||||
$this->setFallbackRates();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نرخ زنده (SystemSetting) دستنخورده میمونه.
|
||||||
|
* فقط یک رکورد «در انتظار تأیید» در تاریخچه ثبت میشه.
|
||||||
|
* مدیر صبح در پنل → تأیید میکنه → اونوقت روی نرخ زنده اعمال میشه.
|
||||||
|
*/
|
||||||
private function updateRate(string $key, float $newRate, string $source, ?int $userId, ?string $apiSource = null): void
|
private function updateRate(string $key, float $newRate, string $source, ?int $userId, ?string $apiSource = null): void
|
||||||
{
|
{
|
||||||
$setting = SystemSetting::where('key', $key)->first();
|
$setting = SystemSetting::where('key', $key)->first();
|
||||||
$oldRate = $setting ? (float) $setting->value : 0;
|
$oldRate = $setting ? (float) $setting->value : 0;
|
||||||
|
|
||||||
// Update در system_settings
|
if ($oldRate > 0 && abs($newRate - $oldRate) > 0.01) {
|
||||||
SystemSetting::updateOrCreate(
|
|
||||||
['key' => $key],
|
|
||||||
['value' => (string) $newRate]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ثبت در تاریخچه فقط اگر تغییر کرده باشد
|
|
||||||
if ($oldRate != $newRate && $oldRate > 0) {
|
|
||||||
$currencyFrom = str_starts_with($key, 'aed') ? 'AED' : 'USD';
|
$currencyFrom = str_starts_with($key, 'aed') ? 'AED' : 'USD';
|
||||||
|
$changePercent = round((($newRate - $oldRate) / $oldRate) * 100, 4);
|
||||||
ExchangeRateHistory::recordChange(
|
|
||||||
from: $currencyFrom,
|
ExchangeRateHistory::create([
|
||||||
to: 'IRR',
|
'currency_from' => $currencyFrom,
|
||||||
oldRate: $oldRate,
|
'currency_to' => 'IRR',
|
||||||
newRate: $newRate,
|
'old_rate' => $oldRate,
|
||||||
source: $source,
|
'new_rate' => $newRate,
|
||||||
userId: $userId,
|
'change_percent' => $changePercent,
|
||||||
apiSource: $apiSource
|
'change_source' => $source,
|
||||||
);
|
'changed_by' => $userId,
|
||||||
|
'api_source' => $apiSource,
|
||||||
|
'notes' => null,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +85,7 @@ class UpdateExchangeRates extends Command
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'aed_to_irr' => round($data['rates']['IRR'] ?? 455000, 2),
|
'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),
|
'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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -8,6 +8,7 @@ enum TransactionType: string
|
|||||||
case WITHDRAWAL = 'withdrawal'; // برداشت / کسر
|
case WITHDRAWAL = 'withdrawal'; // برداشت / کسر
|
||||||
case ORDER_PAYMENT = 'order_payment'; // پرداخت سفارش
|
case ORDER_PAYMENT = 'order_payment'; // پرداخت سفارش
|
||||||
case REFUND = 'refund'; // بازگشت وجه
|
case REFUND = 'refund'; // بازگشت وجه
|
||||||
|
case CREDIT_SETTLEMENT = 'credit_settlement'; // تسویه اعتبار چندارزی از کیف پول
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
@ -16,6 +17,7 @@ enum TransactionType: string
|
|||||||
self::WITHDRAWAL => 'برداشت',
|
self::WITHDRAWAL => 'برداشت',
|
||||||
self::ORDER_PAYMENT => 'پرداخت سفارش',
|
self::ORDER_PAYMENT => 'پرداخت سفارش',
|
||||||
self::REFUND => 'بازگشت وجه',
|
self::REFUND => 'بازگشت وجه',
|
||||||
|
self::CREDIT_SETTLEMENT => 'تسویه اعتبار',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,6 +28,7 @@ enum TransactionType: string
|
|||||||
self::WITHDRAWAL => 'danger',
|
self::WITHDRAWAL => 'danger',
|
||||||
self::ORDER_PAYMENT => 'warning',
|
self::ORDER_PAYMENT => 'warning',
|
||||||
self::REFUND => 'info',
|
self::REFUND => 'info',
|
||||||
|
self::CREDIT_SETTLEMENT => 'info',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
28
04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php
Normal file
28
04_Laravel/app/Filament/Pages/DashboardWidgetRedesign.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* گزینهٔ placeholder برای «طراحی مجدد ویجتهای داشبورد».
|
||||||
|
* پس از تحویل، ویجتهای فعلی بازطراحی خواهند شد. این صفحه فقط یک یادآور
|
||||||
|
* و فهرست ویجتهای موردنظر است.
|
||||||
|
*/
|
||||||
|
class DashboardWidgetRedesign extends Page
|
||||||
|
{
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-swatch';
|
||||||
|
|
||||||
|
protected static string $view = 'filament.pages.dashboard-widget-redesign';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'طراحی مجدد ویجتها';
|
||||||
|
|
||||||
|
protected static ?string $navigationGroup = 'ابزارها';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->hasAnyRole(['super_admin', 'admin']) ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,18 +3,28 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\CustomerCreditResource\Pages;
|
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;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Filament\Tables\Actions\Action;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Filament\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اعتبار چندارزی مشتری (بدهی به ارز خارجی + تسویه ریالی).
|
||||||
|
* - ایجاد: فقط super_admin (canCreate).
|
||||||
|
* - تسویه: super_admin/admin/staff (اکشن روی ردیف).
|
||||||
|
* - کیف پول = مرجع؛ تسویه از کیف پول یک WalletTransaction (type=credit_settlement) میسازد.
|
||||||
|
*/
|
||||||
class CustomerCreditResource extends Resource
|
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 $navigationIcon = 'heroicon-o-banknotes';
|
||||||
protected static ?string $navigationGroup = 'مالی';
|
protected static ?string $navigationGroup = 'مالی';
|
||||||
protected static ?string $navigationLabel = 'اعتبار مشتریان';
|
protected static ?string $navigationLabel = 'اعتبار مشتریان';
|
||||||
@ -24,68 +34,84 @@ class CustomerCreditResource extends Resource
|
|||||||
|
|
||||||
public static function form(Form $form): Form
|
public static function form(Form $form): Form
|
||||||
{
|
{
|
||||||
|
$isCreate = $form->getOperation() === 'create';
|
||||||
|
|
||||||
return $form->schema([
|
return $form->schema([
|
||||||
Forms\Components\Section::make('اطلاعات مشتری')
|
Forms\Components\Section::make('اطلاعات اعتبار')->schema([
|
||||||
->schema([
|
Forms\Components\Select::make('user_id')
|
||||||
Forms\Components\TextInput::make('name')
|
->label('مشتری')
|
||||||
->label('نام')
|
->relationship('user', 'name', fn (\Illuminate\Database\Eloquent\Builder $query) => $query->role('customer')->orderBy('name'))
|
||||||
->disabled(),
|
->searchable()
|
||||||
Forms\Components\TextInput::make('email')
|
->preload()
|
||||||
->label('ایمیل')
|
->required()
|
||||||
->disabled(),
|
->disabled(! $isCreate),
|
||||||
Forms\Components\TextInput::make('phone')
|
|
||||||
->label('موبایل')
|
|
||||||
->disabled(),
|
|
||||||
])->columns(3),
|
|
||||||
|
|
||||||
Forms\Components\Section::make('اطلاعات اعتبار')
|
Forms\Components\TextInput::make('currency_code')
|
||||||
->schema([
|
->label('کد ارز')
|
||||||
Forms\Components\TextInput::make('credit_limit')
|
->placeholder('EUR, USD, AED, CNY')
|
||||||
->label('سقف اعتبار (ریال)')
|
->required()
|
||||||
->numeric()
|
->maxLength(10)
|
||||||
->default(0)
|
->dehydrateStateUsing(fn ($state) => strtoupper(trim($state)))
|
||||||
->minValue(0)
|
->disabled(! $isCreate),
|
||||||
->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\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([
|
->schema([
|
||||||
Forms\Components\Placeholder::make('transactions_history')
|
Forms\Components\Placeholder::make('settled_amount_display')
|
||||||
->label('تراکنشهای اخیر')
|
->label('تسویهشده')
|
||||||
->content(function ($record) {
|
->content(fn ($record) => $record
|
||||||
if (!$record) return '-';
|
? number_format((float) $record->settled_amount, 2) . ' ' . $record->currency_code
|
||||||
|
: '—'),
|
||||||
$transactions = DB::table('wallet_transactions')
|
|
||||||
->where('user_id', $record->id)
|
Forms\Components\Placeholder::make('balance_display')
|
||||||
->orderBy('created_at', 'desc')
|
->label('مانده بدهی')
|
||||||
->take(5)
|
->content(fn ($record) => $record
|
||||||
->get();
|
? number_format($record->balance, 2) . ' ' . $record->currency_code
|
||||||
|
: '—'),
|
||||||
if ($transactions->isEmpty()) return 'تراکنشی ثبت نشده';
|
|
||||||
|
Forms\Components\Placeholder::make('status_display')
|
||||||
$html = '<div style="font-size: 12px; line-height: 1.8;">';
|
->label('وضعیت')
|
||||||
foreach ($transactions as $tx) {
|
->content(fn ($record) => match ($record?->status) {
|
||||||
$type = match($tx->type) {
|
'open' => 'باز',
|
||||||
'credit_add' => '➕ افزایش اعتبار',
|
'partially_settled' => 'نیمهتسویه',
|
||||||
'credit_use' => '➖ استفاده از اعتبار',
|
'settled' => 'تسویهشده',
|
||||||
default => $tx->type,
|
default => '—',
|
||||||
};
|
|
||||||
$html .= '<div>' . $type . ': ' . number_format($tx->amount) . ' ریال - ' . $tx->description . '</div>';
|
|
||||||
}
|
|
||||||
$html .= '</div>';
|
|
||||||
return $html;
|
|
||||||
}),
|
}),
|
||||||
]),
|
|
||||||
|
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
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('id')
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
->label('شناسه')
|
->label('مشتری')
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('name')
|
|
||||||
->label('نام')
|
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('email')
|
|
||||||
->label('ایمیل')
|
Tables\Columns\TextColumn::make('currency_code')
|
||||||
->searchable()
|
->label('ارز')
|
||||||
|
->badge()
|
||||||
|
->color('info'),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('amount')
|
||||||
|
->label('مبلغ')
|
||||||
|
->numeric(2)
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('phone')
|
|
||||||
->label('موبایل')
|
Tables\Columns\TextColumn::make('settled_amount')
|
||||||
->searchable(),
|
->label('تسویهشده')
|
||||||
Tables\Columns\TextColumn::make('credit_limit')
|
->numeric(2)
|
||||||
->label('سقف اعتبار')
|
->color('success'),
|
||||||
->numeric()
|
|
||||||
->formatStateUsing(fn ($state) => number_format($state) . ' ریال')
|
Tables\Columns\TextColumn::make('balance')
|
||||||
->sortable(),
|
->label('مانده')
|
||||||
Tables\Columns\TextColumn::make('credit_used')
|
->numeric(2)
|
||||||
->label('استفاده شده')
|
->badge()
|
||||||
->numeric()
|
->color(fn ($record) => $record->balance > 0 ? 'danger' : 'gray'),
|
||||||
->formatStateUsing(fn ($state) => number_format($state) . ' ریال')
|
|
||||||
->sortable(),
|
Tables\Columns\TextColumn::make('status')
|
||||||
Tables\Columns\TextColumn::make('available_credit')
|
->label('وضعیت')
|
||||||
->label('باقیمانده')
|
->badge()
|
||||||
->numeric()
|
->formatStateUsing(fn (string $state) => match ($state) {
|
||||||
->formatStateUsing(fn ($state) => number_format($state) . ' ریال')
|
'open' => 'باز',
|
||||||
->color(fn ($state) => $state > 0 ? 'success' : 'danger')
|
'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(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('has_credit')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->label('وضعیت اعتبار')
|
->label('وضعیت')
|
||||||
->options([
|
->options([
|
||||||
'has_limit' => 'دارای سقف اعتبار',
|
'open' => 'باز',
|
||||||
'no_limit' => 'بدون سقف اعتبار',
|
'partially_settled' => 'نیمهتسویه',
|
||||||
'has_used' => 'استفاده شده',
|
'settled' => 'تسویهشده',
|
||||||
'has_available' => 'باقیمانده',
|
]),
|
||||||
])
|
Tables\Filters\SelectFilter::make('currency_code')
|
||||||
->query(function ($query, $filter) {
|
->label('ارز')
|
||||||
$state = $filter->getState();
|
->options(
|
||||||
if (is_array($state)) {
|
fn () => CustomerCredit::select('currency_code')
|
||||||
$state = $state['value'] ?? null;
|
->distinct()
|
||||||
}
|
->orderBy('currency_code')
|
||||||
match ($state) {
|
->pluck('currency_code', 'currency_code')
|
||||||
'has_limit' => $query->where('credit_limit', '>', 0),
|
->toArray()
|
||||||
'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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\ViewAction::make(),
|
||||||
Tables\Actions\Action::make('add_credit')
|
|
||||||
->label('افزایش اعتبار')
|
Action::make('settle')
|
||||||
->icon('heroicon-o-plus-circle')
|
->label('ثبت تسویه')
|
||||||
|
->icon('heroicon-o-banknotes')
|
||||||
->color('success')
|
->color('success')
|
||||||
|
->visible(fn ($record) => $record->isOpen()
|
||||||
|
&& auth()->user()?->hasAnyRole(['super_admin', 'admin', 'staff']))
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('amount')
|
Forms\Components\Placeholder::make('balance_info')
|
||||||
->label('مبلغ (ریال)')
|
->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()
|
->numeric()
|
||||||
|
->step(0.01)
|
||||||
|
->minValue(0.01)
|
||||||
->required()
|
->required()
|
||||||
->minValue(1),
|
->helperText(fn ($record) => 'حداکثر: '
|
||||||
Forms\Components\TextInput::make('description')
|
. number_format($record->balance, 2) . ' ' . $record->currency_code),
|
||||||
->label('توضیحات')
|
|
||||||
->required(),
|
|
||||||
])
|
|
||||||
->action(function ($record, array $data): void {
|
|
||||||
DB::beginTransaction();
|
|
||||||
try {
|
|
||||||
$record->credit_limit += $data['amount'];
|
|
||||||
$record->save();
|
|
||||||
|
|
||||||
DB::table('wallet_transactions')->insert([
|
Forms\Components\TextInput::make('exchange_rate')
|
||||||
'user_id' => $record->id,
|
->label('نرخ روز (ریال به یک واحد ارز)')
|
||||||
'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('مبلغ (ریال)')
|
|
||||||
->numeric()
|
->numeric()
|
||||||
|
->step(0.0001)
|
||||||
|
->minValue(0.0001)
|
||||||
->required()
|
->required()
|
||||||
->minValue(1),
|
->helperText(function ($record) {
|
||||||
Forms\Components\TextInput::make('description')
|
$rate = app(ExchangeRateService::class)
|
||||||
->label('توضیحات')
|
->getCurrentRate($record->currency_code, 'IRR');
|
||||||
|
return $rate > 0
|
||||||
|
? 'نرخ فعلی: ' . number_format($rate) . ' ریال'
|
||||||
|
: 'نرخ فعلی ثبت نشده — دستی وارد کنید.';
|
||||||
|
}),
|
||||||
|
|
||||||
|
Forms\Components\DatePicker::make('rate_date')
|
||||||
|
->label('تاریخ نرخ')
|
||||||
|
->default(now())
|
||||||
->required(),
|
->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 {
|
->action(function ($record, array $data) {
|
||||||
DB::beginTransaction();
|
|
||||||
try {
|
try {
|
||||||
$record->credit_limit -= $data['amount'];
|
$settlement = app(CustomerCreditService::class)->settle(
|
||||||
$record->save();
|
$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([
|
Notification::make()
|
||||||
'user_id' => $record->id,
|
->title('تسویه ثبت شد')
|
||||||
'type' => 'credit_reduce',
|
->body(number_format($data['amount_foreign'], 2)
|
||||||
'amount' => $data['amount'],
|
. ' ' . $record->currency_code
|
||||||
'description' => $data['description'],
|
. ' → ' . number_format($settlement->settlement_amount_irr) . ' ریال')
|
||||||
'created_at' => now(),
|
->success()
|
||||||
'updated_at' => now(),
|
->send();
|
||||||
]);
|
} catch (\Throwable $e) {
|
||||||
|
Notification::make()
|
||||||
DB::commit();
|
->title('خطا در تسویه')
|
||||||
} catch (\Exception $e) {
|
->body($e->getMessage())
|
||||||
DB::rollBack();
|
->danger()
|
||||||
throw $e;
|
->send();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
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
|
public static function getRelations(): array
|
||||||
@ -236,12 +291,32 @@ class CustomerCreditResource extends Resource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => Pages\ListCustomerCredits::route('/'),
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CustomerCreditResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\CustomerCreditResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateCustomerCredit extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = CustomerCreditResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلدهای سیستمی (granted_by, granted_at, settled_amount, status)
|
||||||
|
* بهصورت خودکار هنگام ایجاد پر میشوند.
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data['granted_by'] = auth()->id();
|
||||||
|
$data['granted_at'] = now();
|
||||||
|
$data['settled_amount'] = 0;
|
||||||
|
$data['status'] = 'open';
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CustomerCreditResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\CustomerCreditResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewCustomerCredit extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = CustomerCreditResource::class;
|
||||||
|
}
|
||||||
@ -57,6 +57,16 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
->label('توسط')
|
->label('توسط')
|
||||||
->relationship('changedBy', 'name')
|
->relationship('changedBy', 'name')
|
||||||
->disabled(),
|
->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')
|
Forms\Components\Textarea::make('notes')
|
||||||
->label('یادداشت')
|
->label('یادداشت')
|
||||||
->disabled()
|
->disabled()
|
||||||
@ -73,35 +83,35 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
->label('شناسه')
|
->label('شناسه')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('currency_pair')
|
Tables\Columns\TextColumn::make('currency_pair')
|
||||||
->label('جفت ارز')
|
->label('جفت ارز')
|
||||||
->state(fn ($record) => "{$record->currency_from} → {$record->currency_to}")
|
->state(fn ($record) => "{$record->currency_from} → {$record->currency_to}")
|
||||||
->badge()
|
->badge()
|
||||||
->color('info'),
|
->color('info'),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('old_rate')
|
Tables\Columns\TextColumn::make('old_rate')
|
||||||
->label('نرخ قبلی')
|
->label('نرخ قبلی')
|
||||||
->numeric(0)
|
->numeric(0)
|
||||||
->suffix(' ریال')
|
->suffix(' ریال')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('new_rate')
|
Tables\Columns\TextColumn::make('new_rate')
|
||||||
->label('نرخ جدید')
|
->label('نرخ جدید')
|
||||||
->numeric(0)
|
->numeric(0)
|
||||||
->suffix(' ریال')
|
->suffix(' ریال')
|
||||||
->weight('bold')
|
->weight('bold')
|
||||||
->color('primary'),
|
->color('primary'),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('change_percent')
|
Tables\Columns\TextColumn::make('change_percent')
|
||||||
->label('تغییر')
|
->label('تغییر')
|
||||||
->state(fn ($record) => $record->change_percent !== null
|
->state(fn ($record) => $record->change_percent !== null
|
||||||
? number_format($record->change_percent, 2) . '%'
|
? number_format($record->change_percent, 2) . '%'
|
||||||
: '—')
|
: '—')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn ($record) => match(true) {
|
->color(fn ($record) => match(true) {
|
||||||
$record->change_percent > 0 => 'danger', // افزایش قیمت = قرمز
|
$record->change_percent > 0 => 'danger',
|
||||||
$record->change_percent < 0 => 'success', // کاهش قیمت = سبز
|
$record->change_percent < 0 => 'success',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
})
|
})
|
||||||
->icon(fn ($record) => match(true) {
|
->icon(fn ($record) => match(true) {
|
||||||
@ -109,27 +119,59 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
$record->change_percent < 0 => 'heroicon-m-arrow-trending-down',
|
$record->change_percent < 0 => 'heroicon-m-arrow-trending-down',
|
||||||
default => null,
|
default => null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('change_source')
|
Tables\Columns\TextColumn::make('change_source')
|
||||||
->label('منبع')
|
->label('منبع')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state) => $state === 'api' ? 'API خودکار' : 'دستی')
|
->formatStateUsing(fn ($state) => $state === 'api' ? 'API خودکار' : 'دستی')
|
||||||
->color(fn ($state) => $state === 'api' ? 'info' : 'warning')
|
->color(fn ($state) => $state === 'api' ? 'info' : 'warning')
|
||||||
->icon(fn ($state) => $state === 'api'
|
->icon(fn ($state) => $state === 'api'
|
||||||
? 'heroicon-m-cog-6-tooth'
|
? 'heroicon-m-cog-6-tooth'
|
||||||
: 'heroicon-m-pencil-square'),
|
: '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')
|
Tables\Columns\TextColumn::make('changedBy.name')
|
||||||
->label('توسط')
|
->label('توسط')
|
||||||
->state(fn ($record) => $record->changedBy?->name ?? ($record->change_source === 'api' ? 'سیستم' : '—'))
|
->state(fn ($record) => $record->changedBy?->name ?? ($record->change_source === 'api' ? 'سیستم' : '—'))
|
||||||
->icon('heroicon-m-user'),
|
->icon('heroicon-m-user'),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('تاریخ تغییر')
|
->label('تاریخ تغییر')
|
||||||
->dateTime('Y/m/d H:i')
|
->dateTime('Y/m/d H:i')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->label('وضعیت')
|
||||||
|
->options([
|
||||||
|
'pending' => 'در انتظار',
|
||||||
|
'approved' => 'تأیید شده',
|
||||||
|
'rejected' => 'رد شده',
|
||||||
|
'superseded' => 'جایگذشته',
|
||||||
|
]),
|
||||||
Tables\Filters\SelectFilter::make('change_source')
|
Tables\Filters\SelectFilter::make('change_source')
|
||||||
->label('منبع')
|
->label('منبع')
|
||||||
->options([
|
->options([
|
||||||
@ -161,9 +203,41 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\ViewAction::make()->label('جزئیات'),
|
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([
|
->headerActions([
|
||||||
// 🎯 دکمه اصلی: تنظیم نرخ جدید به صورت دستی
|
|
||||||
Action::make('setNewRate')
|
Action::make('setNewRate')
|
||||||
->label('تنظیم نرخ جدید')
|
->label('تنظیم نرخ جدید')
|
||||||
->icon('heroicon-o-pencil-square')
|
->icon('heroicon-o-pencil-square')
|
||||||
@ -177,25 +251,25 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
'USD_IRR' => 'دلار به ریال (USD → IRR)',
|
'USD_IRR' => 'دلار به ریال (USD → IRR)',
|
||||||
])
|
])
|
||||||
->default('AED_IRR'),
|
->default('AED_IRR'),
|
||||||
|
|
||||||
Forms\Components\TextInput::make('new_rate')
|
Forms\Components\TextInput::make('new_rate')
|
||||||
->label('نرخ جدید (ریال)')
|
->label('نرخ جدید (ریال)')
|
||||||
->required()
|
->required()
|
||||||
->numeric()
|
->numeric()
|
||||||
->minValue(1)
|
->minValue(1)
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->helperText(fn (callable $get) =>
|
->helperText(fn (callable $get) =>
|
||||||
'نرخ فعلی: ' . number_format(self::getCurrentRateForPair($get('currency_pair'))) . ' ریال'
|
'نرخ فعلی: ' . number_format(self::getCurrentRateForPair($get('currency_pair'))) . ' ریال'
|
||||||
),
|
),
|
||||||
|
|
||||||
Forms\Components\Textarea::make('notes')
|
Forms\Components\Textarea::make('notes')
|
||||||
->label('یادداشت (اختیاری)')
|
->label('یادداشت (اختیارر)')
|
||||||
->placeholder('دلیل تغییر نرخ...')
|
->placeholder('دلیل تغییر نرخ...')
|
||||||
->maxLength(500),
|
->maxLength(500),
|
||||||
])
|
])
|
||||||
->action(function (array $data) {
|
->action(function (array $data) {
|
||||||
[$from, $to] = explode('_', $data['currency_pair']);
|
[$from, $to] = explode('_', $data['currency_pair']);
|
||||||
|
|
||||||
$service = app(ExchangeRateService::class);
|
$service = app(ExchangeRateService::class);
|
||||||
$service->setRate(
|
$service->setRate(
|
||||||
from: $from,
|
from: $from,
|
||||||
@ -204,28 +278,27 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
userId: Auth::id(),
|
userId: Auth::id(),
|
||||||
notes: $data['notes'] ?? null
|
notes: $data['notes'] ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('نرخ ارز با موفقیت بهروز شد')
|
->title('نرخ ارز با موفقیت بهروز شد')
|
||||||
->body("نرخ {$from}/{$to} به " . number_format($data['new_rate']) . " ریال تغییر یافت")
|
->body("نرخ {$from}/{$to} به " . number_format($data['new_rate']) . " ریال تغییر یافت")
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 🔄 دکمه آپدیت از API
|
|
||||||
Action::make('refreshFromApi')
|
Action::make('refreshFromApi')
|
||||||
->label('آپدیت از API')
|
->label('آپدیت از API')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('info')
|
->color('info')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('آپدیت نرخ از API')
|
->modalHeading('آپدیت نرخ از API')
|
||||||
->modalDescription('آیا مطمئن هستید که میخواهید نرخ ارز را از API بهروزرسانی کنید؟')
|
->modalDescription('این عمل یک نرخ پیشنهادی «در انتظار تأیید» میسازد. نرخ زنده تغییر نمیکند تا تأیید کنید.')
|
||||||
->action(function () {
|
->action(function () {
|
||||||
try {
|
try {
|
||||||
\Artisan::call('ifnex:update-rates');
|
\Artisan::call('ifnex:update-rates');
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('نرخ ارز از API بهروزرسانی شد')
|
->title('نرخ پیشنهادی API ساخته شد — منتظر تأیید شما')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@ -239,10 +312,9 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->poll('60s'); // هر ۶۰ ثانیه خودکار refresh میشود
|
->poll('60s');
|
||||||
}
|
}
|
||||||
|
|
||||||
// متد کمکی برای نمایش نرخ فعلی در فرم
|
|
||||||
protected static function getCurrentRateForPair(?string $pair): float
|
protected static function getCurrentRateForPair(?string $pair): float
|
||||||
{
|
{
|
||||||
if (!$pair) return 0;
|
if (!$pair) return 0;
|
||||||
@ -263,7 +335,6 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// فقط admin و super_admin دسترسی دارند
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
@ -272,7 +343,7 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
return false; // ایجاد دستی از فرم ممکن نیست، فقط از Action
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canEdit($record): bool
|
public static function canEdit($record): bool
|
||||||
@ -284,4 +355,15 @@ class ExchangeRateHistoryResource extends Resource
|
|||||||
{
|
{
|
||||||
return false;
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -11,7 +11,7 @@ class ExchangeRateWidget extends BaseWidget
|
|||||||
{
|
{
|
||||||
protected static ?int $sort = 2;
|
protected static ?int $sort = 2;
|
||||||
protected static bool $isLazy = false;
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
protected int | string | array $columnSpan = 'full';
|
protected int | string | array $columnSpan = 'full';
|
||||||
|
|
||||||
protected function getStats(): array
|
protected function getStats(): array
|
||||||
@ -19,17 +19,16 @@ class ExchangeRateWidget extends BaseWidget
|
|||||||
$service = app(ExchangeRateService::class);
|
$service = app(ExchangeRateService::class);
|
||||||
$aedInfo = $service->getLatestRateInfo('AED', 'IRR');
|
$aedInfo = $service->getLatestRateInfo('AED', 'IRR');
|
||||||
$usdInfo = $service->getLatestRateInfo('USD', 'IRR');
|
$usdInfo = $service->getLatestRateInfo('USD', 'IRR');
|
||||||
|
|
||||||
$aedChangePercent = $aedInfo['change_percent'];
|
$aedChangePercent = $aedInfo['change_percent'];
|
||||||
$usdChangePercent = $usdInfo['change_percent'];
|
$usdChangePercent = $usdInfo['change_percent'];
|
||||||
|
|
||||||
// ✅ استفاده از متد جدید getRateChart (بدون scope)
|
|
||||||
$aedChart = $service->getRateChart('AED', 'IRR', 7);
|
$aedChart = $service->getRateChart('AED', 'IRR', 7);
|
||||||
$usdChart = $service->getRateChart('USD', 'IRR', 7);
|
$usdChart = $service->getRateChart('USD', 'IRR', 7);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Stat::make('نرخ درهم (AED)', number_format($aedInfo['current_rate']) . ' ریال')
|
Stat::make('نرخ درهم (AED)', number_format($aedInfo['current_rate']) . ' ریال')
|
||||||
->description($aedChangePercent !== null
|
->description($aedChangePercent !== null
|
||||||
? 'تغییر: ' . number_format($aedChangePercent, 2) . '% — توسط ' . ($aedInfo['changed_by'] ?? 'سیستم')
|
? 'تغییر: ' . number_format($aedChangePercent, 2) . '% — توسط ' . ($aedInfo['changed_by'] ?? 'سیستم')
|
||||||
: 'آخرین تغییر: ' . ($aedInfo['last_updated']?->format('Y/m/d H:i') ?? '—'))
|
: 'آخرین تغییر: ' . ($aedInfo['last_updated']?->format('Y/m/d H:i') ?? '—'))
|
||||||
->descriptionIcon(
|
->descriptionIcon(
|
||||||
@ -41,9 +40,9 @@ class ExchangeRateWidget extends BaseWidget
|
|||||||
($aedChangePercent < 0 ? 'success' : 'gray')
|
($aedChangePercent < 0 ? 'success' : 'gray')
|
||||||
)
|
)
|
||||||
->chart(!empty($aedChart) ? $aedChart : [0]),
|
->chart(!empty($aedChart) ? $aedChart : [0]),
|
||||||
|
|
||||||
Stat::make('نرخ دلار (USD)', number_format($usdInfo['current_rate']) . ' ریال')
|
Stat::make('نرخ دلار (USD)', number_format($usdInfo['current_rate']) . ' ریال')
|
||||||
->description($usdChangePercent !== null
|
->description($usdChangePercent !== null
|
||||||
? 'تغییر: ' . number_format($usdChangePercent, 2) . '% — توسط ' . ($usdInfo['changed_by'] ?? 'سیستم')
|
? 'تغییر: ' . number_format($usdChangePercent, 2) . '% — توسط ' . ($usdInfo['changed_by'] ?? 'سیستم')
|
||||||
: 'آخرین تغییر: ' . ($usdInfo['last_updated']?->format('Y/m/d H:i') ?? '—'))
|
: 'آخرین تغییر: ' . ($usdInfo['last_updated']?->format('Y/m/d H:i') ?? '—'))
|
||||||
->descriptionIcon(
|
->descriptionIcon(
|
||||||
@ -55,13 +54,18 @@ class ExchangeRateWidget extends BaseWidget
|
|||||||
($usdChangePercent < 0 ? 'success' : 'gray')
|
($usdChangePercent < 0 ? 'success' : 'gray')
|
||||||
)
|
)
|
||||||
->chart(!empty($usdChart) ? $usdChart : [0]),
|
->chart(!empty($usdChart) ? $usdChart : [0]),
|
||||||
|
|
||||||
Stat::make('تغییرات امروز',
|
Stat::make('تغییرات امروز',
|
||||||
ExchangeRateHistory::whereDate('created_at', today())->count()
|
ExchangeRateHistory::whereDate('created_at', today())->count()
|
||||||
)
|
)
|
||||||
->description('تغییرات نرخ ثبتشده در امروز')
|
->description('تغییرات نرخ ثبتشده در امروز')
|
||||||
->descriptionIcon('heroicon-m-arrow-path')
|
->descriptionIcon('heroicon-m-arrow-path')
|
||||||
->color('info'),
|
->color('info'),
|
||||||
|
|
||||||
|
Stat::make('در انتظار تأیید', $service->getPendingCount())
|
||||||
|
->description('نرخهای پیشنهادی API — منتظر تأیید مدیر')
|
||||||
|
->descriptionIcon('heroicon-m-clock')
|
||||||
|
->color('warning'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,6 +7,7 @@ use App\Models\User;
|
|||||||
use App\Models\Shipment;
|
use App\Models\Shipment;
|
||||||
use App\Models\WalletTransaction;
|
use App\Models\WalletTransaction;
|
||||||
use App\Enums\ShipmentStatus;
|
use App\Enums\ShipmentStatus;
|
||||||
|
use App\Services\CustomerCreditService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@ -60,16 +61,8 @@ class CustomerFinancialController extends Controller
|
|||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// محاسبه بدهی به هر ارز
|
// بدهیهای چندارزی واقعی (از customer_credits) — به ارز خارجی، نه معادل ریالی
|
||||||
$debts_by_currency = $pending_orders->groupBy(function ($order) {
|
$foreign_credits = app(CustomerCreditService::class)->getCustomerDebtsByCurrency($customer);
|
||||||
return $order->fromCountry?->iso_code ?? 'unknown';
|
|
||||||
})->map(function ($orders, $currency) {
|
|
||||||
return [
|
|
||||||
'currency' => $currency,
|
|
||||||
'total' => $orders->sum('total_fee'),
|
|
||||||
'count' => $orders->count(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
// دریافت تراکنشهای اخیر
|
// دریافت تراکنشهای اخیر
|
||||||
$recent_transactions = WalletTransaction::whereHas('wallet', function ($q) use ($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'),
|
'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;
|
$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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@ -125,10 +119,10 @@ class CustomerFinancialController extends Controller
|
|||||||
],
|
],
|
||||||
'financial' => [
|
'financial' => [
|
||||||
'wallet_balance' => $wallet_balance,
|
'wallet_balance' => $wallet_balance,
|
||||||
'total_debt' => $total_debt,
|
|
||||||
'account_balance' => $account_balance,
|
'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(),
|
'pending_orders_count' => $pending_orders->count(),
|
||||||
|
'foreign_credits' => $foreign_credits,
|
||||||
],
|
],
|
||||||
'recent_transactions' => $recent_transactions,
|
'recent_transactions' => $recent_transactions,
|
||||||
'recent_orders' => $recent_orders,
|
'recent_orders' => $recent_orders,
|
||||||
|
|||||||
52
04_Laravel/app/Models/CreditSettlement.php
Normal file
52
04_Laravel/app/Models/CreditSettlement.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use App\Traits\Auditable;
|
||||||
|
|
||||||
|
class CreditSettlement extends Model
|
||||||
|
{
|
||||||
|
use Auditable;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_credit_id',
|
||||||
|
'settled_amount_foreign',
|
||||||
|
'currency_code',
|
||||||
|
'settlement_currency',
|
||||||
|
'exchange_rate',
|
||||||
|
'rate_date',
|
||||||
|
'settlement_amount_irr',
|
||||||
|
'wallet_transaction_id',
|
||||||
|
'reason',
|
||||||
|
'reference',
|
||||||
|
'settled_by',
|
||||||
|
'settled_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'settled_amount_foreign' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
92
04_Laravel/app/Models/CustomerCredit.php
Normal file
92
04_Laravel/app/Models/CustomerCredit.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use App\Traits\Auditable;
|
||||||
|
|
||||||
|
class CustomerCredit extends Model
|
||||||
|
{
|
||||||
|
use Auditable;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'currency_code',
|
||||||
|
'amount',
|
||||||
|
'settled_amount',
|
||||||
|
'status',
|
||||||
|
'reason',
|
||||||
|
'reference',
|
||||||
|
'shipment_id',
|
||||||
|
'granted_by',
|
||||||
|
'granted_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,10 @@ class ExchangeRateHistory extends Model
|
|||||||
'new_rate',
|
'new_rate',
|
||||||
'change_percent',
|
'change_percent',
|
||||||
'change_source',
|
'change_source',
|
||||||
|
'status',
|
||||||
'changed_by',
|
'changed_by',
|
||||||
|
'approved_by',
|
||||||
|
'approved_at',
|
||||||
'api_source',
|
'api_source',
|
||||||
'notes',
|
'notes',
|
||||||
];
|
];
|
||||||
@ -25,10 +28,28 @@ class ExchangeRateHistory extends Model
|
|||||||
'old_rate' => 'decimal:2',
|
'old_rate' => 'decimal:2',
|
||||||
'new_rate' => 'decimal:2',
|
'new_rate' => 'decimal:2',
|
||||||
'change_percent' => 'decimal:4',
|
'change_percent' => 'decimal:4',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function changedBy(): BelongsTo
|
public function changedBy(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'changed_by');
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -148,7 +148,7 @@ class Shipment extends Model
|
|||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تاریخچه تغییرات وضعیت مرسوله
|
* تاریخچه تغییرات وضعیت مرسوله
|
||||||
*/
|
*/
|
||||||
public function statusHistories(): HasMany
|
public function statusHistories(): HasMany
|
||||||
@ -157,6 +157,14 @@ class Shipment extends Model
|
|||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اعتبارها (بدهیهای چندارزی) مرتبط با این مرسوله
|
||||||
|
*/
|
||||||
|
public function credits(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CustomerCredit::class);
|
||||||
|
}
|
||||||
|
|
||||||
// === Scopes ===
|
// === Scopes ===
|
||||||
|
|
||||||
public function scopeByAwb($query, string $awbNo)
|
public function scopeByAwb($query, string $awbNo)
|
||||||
|
|||||||
@ -41,11 +41,19 @@ class User extends Authenticatable
|
|||||||
|
|
||||||
// ─── Relationships ───────────────────────────────
|
// ─── Relationships ───────────────────────────────
|
||||||
|
|
||||||
public function wallet()
|
public function wallet()
|
||||||
{
|
{
|
||||||
return $this->hasOne(Wallet::class);
|
return $this->hasOne(Wallet::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اعتبارها (بدهیهای چندارزی) کاربر
|
||||||
|
*/
|
||||||
|
public function credits()
|
||||||
|
{
|
||||||
|
return $this->hasMany(CustomerCredit::class)->orderBy('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
public function notifications()
|
public function notifications()
|
||||||
{
|
{
|
||||||
return $this->morphMany(DatabaseNotification::class, 'notifiable')->orderByDesc('created_at');
|
return $this->morphMany(DatabaseNotification::class, 'notifiable')->orderByDesc('created_at');
|
||||||
|
|||||||
38
04_Laravel/app/Policies/CustomerCreditPolicy.php
Normal file
38
04_Laravel/app/Policies/CustomerCreditPolicy.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\CustomerCredit;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اعتبار چندارزی — فقط مدیر کل میتواند ایجاد/ویرایش/حذف کند.
|
||||||
|
* مشاهده: super_admin, admin, staff. تسویه: اکشن جداگانه در Resource.
|
||||||
|
*/
|
||||||
|
class CustomerCreditPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
177
04_Laravel/app/Services/CustomerCreditService.php
Normal file
177
04_Laravel/app/Services/CustomerCreditService.php
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\PaymentGateway;
|
||||||
|
use App\Enums\TransactionStatus;
|
||||||
|
use App\Enums\TransactionType;
|
||||||
|
use App\Models\CreditSettlement;
|
||||||
|
use App\Models\CustomerCredit;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WalletTransaction;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سرویس اعتبار چندارزی مشتری.
|
||||||
|
* کیف پول (IRR) مرجع است. اعتبار = بدهی به ارز خارجی. تسویه = پرداخت ریالی با نرخ روز.
|
||||||
|
*/
|
||||||
|
class CustomerCreditService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* اعطای اعتبار (بدهی) چندارزی. فقط super_admin (Policy).
|
||||||
|
*/
|
||||||
|
public function grantCredit(
|
||||||
|
User $customer,
|
||||||
|
string $currencyCode,
|
||||||
|
float $amount,
|
||||||
|
?string $reason = null,
|
||||||
|
?string $reference = null,
|
||||||
|
?int $shipmentId = null,
|
||||||
|
int $grantedBy
|
||||||
|
): CustomerCredit {
|
||||||
|
if ($amount <= 0) {
|
||||||
|
throw new RuntimeException('مبلغ اعتبار باید مثبت باشد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use (
|
||||||
|
$customer, $currencyCode, $amount, $reason, $reference, $shipmentId, $grantedBy
|
||||||
|
) {
|
||||||
|
return CustomerCredit::create([
|
||||||
|
'user_id' => $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<int, array{currency_code: string, total_amount: float, total_settled: float, balance: float, count: int}>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,26 +4,28 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\SystemSetting;
|
use App\Models\SystemSetting;
|
||||||
use App\Models\ExchangeRateHistory;
|
use App\Models\ExchangeRateHistory;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class ExchangeRateService
|
class ExchangeRateService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* دریافت نرخ فعلی
|
* دریافت نرخ فعلی (زنده = تأییدشده)
|
||||||
*/
|
*/
|
||||||
public function getCurrentRate(string $from = 'AED', string $to = 'IRR'): float
|
public function getCurrentRate(string $from = 'AED', string $to = 'IRR'): float
|
||||||
{
|
{
|
||||||
$key = strtolower("{$from}_to_{$to}");
|
$key = strtolower("{$from}_to_{$to}");
|
||||||
$setting = SystemSetting::where('key', $key)->first();
|
$setting = SystemSetting::where('key', $key)->first();
|
||||||
|
|
||||||
if (!$setting) {
|
if (!$setting) {
|
||||||
return config('ifnex.financial_defaults.aed_to_irr', 455000);
|
return config('ifnex.financial_defaults.aed_to_irr', 455000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (float) $setting->value;
|
return (float) $setting->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تنظیم نرخ جدید (توسط کاربر)
|
* تنظیم نرخ جدید بهصورت دستی (توسط مدیر) → مستقیم اعمال میشه (status=approved)
|
||||||
*/
|
*/
|
||||||
public function setRate(
|
public function setRate(
|
||||||
string $from,
|
string $from,
|
||||||
@ -67,18 +69,17 @@ class ExchangeRateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* دریافت اطلاعات آخرین نرخ برای Widget
|
* دریافت اطلاعات آخرین نرخ (تأییدشده) برای Widget
|
||||||
* ⚠️ بدون استفاده از scope - کوئری مستقیم
|
|
||||||
*/
|
*/
|
||||||
public function getLatestRateInfo(string $from = 'AED', string $to = 'IRR'): array
|
public function getLatestRateInfo(string $from = 'AED', string $to = 'IRR'): array
|
||||||
{
|
{
|
||||||
// ✅ کوئری مستقیم بدون scope
|
|
||||||
$latest = ExchangeRateHistory::where('currency_from', $from)
|
$latest = ExchangeRateHistory::where('currency_from', $from)
|
||||||
->where('currency_to', $to)
|
->where('currency_to', $to)
|
||||||
|
->where('status', 'approved') // فقط تغییرات تأییدشده
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->with('changedBy')
|
->with('changedBy')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$currentRate = $this->getCurrentRate($from, $to);
|
$currentRate = $this->getCurrentRate($from, $to);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -92,12 +93,13 @@ class ExchangeRateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* دریافت آخرین N نرخ برای Chart
|
* دریافت آخرین N نرخ (تأییدشده) برای Chart
|
||||||
*/
|
*/
|
||||||
public function getRateChart(string $from = 'AED', string $to = 'IRR', int $limit = 7): array
|
public function getRateChart(string $from = 'AED', string $to = 'IRR', int $limit = 7): array
|
||||||
{
|
{
|
||||||
return ExchangeRateHistory::where('currency_from', $from)
|
return ExchangeRateHistory::where('currency_from', $from)
|
||||||
->where('currency_to', $to)
|
->where('currency_to', $to)
|
||||||
|
->where('status', 'approved') // فقط تغییرات تأییدشده
|
||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->pluck('new_rate')
|
->pluck('new_rate')
|
||||||
@ -105,7 +107,7 @@ class ExchangeRateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ثبت تغییر نرخ
|
* ثبت تغییر نرخ (داخلی). ورود دستی → status=approved (مستقیم اعمال).
|
||||||
*/
|
*/
|
||||||
private function recordChange(
|
private function recordChange(
|
||||||
string $from,
|
string $from,
|
||||||
@ -123,15 +125,88 @@ class ExchangeRateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ExchangeRateHistory::create([
|
return ExchangeRateHistory::create([
|
||||||
'currency_from' => $from,
|
'currency_from' => $from,
|
||||||
'currency_to' => $to,
|
'currency_to' => $to,
|
||||||
'old_rate' => $oldRate,
|
'old_rate' => $oldRate,
|
||||||
'new_rate' => $newRate,
|
'new_rate' => $newRate,
|
||||||
'change_percent' => $changePercent,
|
'change_percent' => $changePercent,
|
||||||
'change_source' => $source,
|
'change_source' => $source,
|
||||||
'changed_by' => $userId,
|
'changed_by' => $userId,
|
||||||
'api_source' => $apiSource,
|
'approved_by' => $userId, // ورود دستی = خود مدیر تأیید کرده
|
||||||
'notes' => $notes,
|
'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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* افزودن روال تأیید نرخ به تاریخچه نرخ ارز.
|
||||||
|
*
|
||||||
|
* - status: pending / approved / rejected / superseded
|
||||||
|
* - approved_by / approved_at: کاربر و زمان تأیید
|
||||||
|
*
|
||||||
|
* نکته: پیشفرض 'approved' تا رکوردهای تاریخی موجود بهعنوان تأییدشده در نظر گرفته بشن.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('exchange_rate_history', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دفتر بدهی چندارزی مشتری.
|
||||||
|
* هر رکورد = یک اعتبار (بدهی) به ارز خارجی (EUR/USD/AED/CNY/...).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('customer_credits', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسویههای ریالی بدهی چندارزی.
|
||||||
|
* هر تسویه = پرداخت ریالی (از کیف پول یا خارجی) برای بخشی از یک customer_credit.
|
||||||
|
* نرخ روز در رکورد ثبت میشود (immutable برای حسابرسی).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('credit_settlements', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* افزودن 'credit_settlement' به enum نوع تراکنش کیف پول.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// SQLite: ENUM بهصورت string ذخیره میشه، نیازی به MODIFY نیست
|
||||||
|
if (DB::connection()->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 'نوع تراکنش'
|
||||||
|
");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot:heading>
|
||||||
|
<x-filament::icon icon="heroicon-o-swatch" class="h-5 w-5 text-amber-500" />
|
||||||
|
طراحی مجدد ویجتهای داشبورد — پیشرو (پس از تحویل)
|
||||||
|
</x-slot:heading>
|
||||||
|
|
||||||
|
<div class="prose dark:prose-invert max-w-none">
|
||||||
|
<p class="text-sm">
|
||||||
|
این بخش برای فاز بعد از تحویل در نظر گرفته شده است. ویجتهای فعلی داشبورد عملکردی هستند
|
||||||
|
و درست کار میکنند، ولی طراحی ظاهری نهایی نشده است.
|
||||||
|
پس از تحویل پروژه به مشتری، ویجتهای زیر بازطراحی خواهند شد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot:heading>
|
||||||
|
<x-filament::icon icon="heroicon-o-rectangle-group" class="h-5 w-5 text-gray-500" />
|
||||||
|
ویجتهایی که بازطراحی خواهند شد
|
||||||
|
</x-slot:heading>
|
||||||
|
|
||||||
|
<ul class="space-y-2 text-sm">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
|
||||||
|
<span><strong>DashboardInfoWidget</strong> — آمار کلی مرسولات</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
|
||||||
|
<span><strong>ExchangeRateWidget</strong> — نرخ ارز + نمودار + تعداد در انتظار تأیید</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
|
||||||
|
<span><strong>WalletStats</strong> — آمار کیف پول</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
|
||||||
|
<span><strong>TransactionChartWidget</strong> — نمودار تراکنشها</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
|
||||||
|
<span><strong>FinanceOverviewWidget</strong> — نمای کلی مالی</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<x-filament::icon icon="heroicon-o-chevron-left" class="h-4 w-4 text-gray-400" />
|
||||||
|
<span><strong>RecentTransactionsWidget</strong> — تراکنشهای اخیر</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
Loading…
Reference in New Issue
Block a user