ifnex/04_Laravel/app/Filament/Resources/CustomerCreditResource.php
Kazem Alghasi 93ebbb1fc8 feat(finance): implement multi-currency credit tracking and dashboard widgets
Integrate multi-currency debt visibility across the platform by adding
support for foreign currency credits in the user profile shortcode,
improving the admin dashboard with unsettled credits widgets, and
enhancing the Filament admin panel with navigation badges and relation
managers.

- Add multi-currency debt table to WordPress user profile shortcode
- Implement `UnsettledCreditsWidget` for Filament dashboard
- Add navigation badge to `CustomerCreditResource` for open credits
- Add `CreditsRelationManager` to `UserResource`
- Update `CustomerOrderController` API to include `foreign_credits`
- Add concurrency check in `CustomerCreditService` during settlement
2026-10-04 01:01:20 +03:30

336 lines
14 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CustomerCreditResource\Pages;
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 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 = CustomerCredit::class;
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
protected static ?string $navigationGroup = 'مالی';
protected static ?string $navigationLabel = 'اعتبار مشتریان';
protected static ?string $modelLabel = 'اعتبار مشتری';
protected static ?string $pluralModelLabel = 'اعتبار مشتریان';
protected static ?int $navigationSort = 4;
public static function form(Form $form): Form
{
$isCreate = $form->getOperation() === 'create';
return $form->schema([
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\TextInput::make('currency_code')
->label('کد ارز')
->placeholder('EUR, USD, AED, CNY')
->required()
->maxLength(10)
->dehydrateStateUsing(fn ($state) => strtoupper(trim($state)))
->disabled(! $isCreate),
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('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),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('مشتری')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('currency_code')
->label('ارز')
->badge()
->color('info'),
Tables\Columns\TextColumn::make('amount')
->label('مبلغ')
->numeric(2)
->sortable(),
Tables\Columns\TextColumn::make('settled_amount')
->label('تسویه‌شده')
->numeric(2)
->color('success'),
Tables\Columns\TextColumn::make('balance')
->label('مانده')
->numeric(2)
->badge()
->color(fn ($record) => $record->balance > 0 ? 'danger' : 'gray'),
Tables\Columns\TextColumn::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn (string $state) => match ($state) {
'open' => 'باز',
'partially_settled' => 'نیمه‌تسویه',
'settled' => 'تسویه‌شده',
default => $state,
})
->color(fn (string $state) => match ($state) {
'open' => 'danger',
'partially_settled' => 'warning',
'settled' => 'success',
default => 'gray',
}),
Tables\Columns\TextColumn::make('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('status')
->label('وضعیت')
->options([
'open' => 'باز',
'partially_settled' => 'نیمه‌تسویه',
'settled' => 'تسویه‌شده',
]),
Tables\Filters\SelectFilter::make('currency_code')
->label('ارز')
->options(
fn () => CustomerCredit::select('currency_code')
->distinct()
->orderBy('currency_code')
->pluck('currency_code', 'currency_code')
->toArray()
),
])
->actions([
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\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()
->helperText(fn ($record) => 'حداکثر: '
. number_format($record->balance, 2) . ' ' . $record->currency_code),
Forms\Components\TextInput::make('exchange_rate')
->label('نرخ روز (ریال به یک واحد ارز)')
->numeric()
->step(0.0001)
->minValue(0.0001)
->required()
->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) {
try {
$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,
);
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()
->visible(fn () => auth()->user()?->hasRole('super_admin')),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCustomerCredits::route('/'),
'create' => Pages\CreateCustomerCredit::route('/create'),
'view' => Pages\ViewCustomerCredit::route('/{record}'),
];
}
/**
* Badge روی منوی سایدبار — تعداد اعتبارهای باز (تسویه‌نشده).
*/
public static function getNavigationBadge(): ?string
{
$count = \App\Models\CustomerCredit::whereIn('status', ['open', 'partially_settled'])->count();
return $count > 0 ? (string) ifnex_persian_digits(number_format($count)) : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'danger';
}
// ─── Authorization ────────────────────────────────
public static function canViewAny(): bool
{
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;
}
}