diff --git a/01_Documents/Sheet ENG invoice.pdf b/01_Documents/Sheet ENG invoice.pdf new file mode 100644 index 0000000..283ed68 Binary files /dev/null and b/01_Documents/Sheet ENG invoice.pdf differ diff --git a/01_Documents/Sheet ENG invoice.xlsx b/01_Documents/Sheet ENG invoice.xlsx new file mode 100644 index 0000000..99b3979 Binary files /dev/null and b/01_Documents/Sheet ENG invoice.xlsx differ diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css index 915b764..1e52f79 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css @@ -448,3 +448,164 @@ gap: 0.5rem !important; } } + +/* ============================================ + ORDER FORM STEPS + ============================================ */ +.ifnex-form-step { + display: none !important; +} + +.ifnex-form-step.active { + display: block !important; +} + +.ifnex-steps { + display: flex !important; + justify-content: space-between !important; + margin-bottom: 2rem !important; + padding: 1rem !important; + background: var(--ifnex-gray-50) !important; + border-radius: var(--ifnex-radius-lg) !important; + border: 1px solid var(--ifnex-gray-200) !important; +} + +.ifnex-step { + flex: 1 !important; + text-align: center !important; + padding: 0.5rem !important; + position: relative !important; + color: var(--ifnex-gray-400) !important; + font-size: 0.75rem !important; + transition: all 0.3s ease !important; +} + +.ifnex-step span { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 28px !important; + height: 28px !important; + border-radius: 50% !important; + background: var(--ifnex-gray-200) !important; + color: var(--ifnex-gray-500) !important; + font-weight: 600 !important; + font-size: 0.875rem !important; + margin-bottom: 0.25rem !important; +} + +.ifnex-step.active { + color: var(--ifnex-primary) !important; + font-weight: 600 !important; +} + +.ifnex-step.active span { + background: var(--ifnex-primary) !important; + color: var(--ifnex-white) !important; +} + +.ifnex-step.done { + color: var(--ifnex-success) !important; +} + +.ifnex-step.done span { + background: var(--ifnex-success) !important; + color: var(--ifnex-white) !important; +} + +.ifnex-form-nav { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + margin-top: 1.5rem !important; + padding-top: 1rem !important; + border-top: 1px solid var(--ifnex-gray-200) !important; +} + +.ifnex-form-card { + background: var(--ifnex-white) !important; + border: 1px solid var(--ifnex-gray-200) !important; + border-radius: var(--ifnex-radius-lg) !important; + padding: 1.5rem !important; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05) !important; +} + +.ifnex-form-card h3 { + font-size: 1rem !important; + font-weight: 600 !important; + color: var(--ifnex-gray-900) !important; + margin-bottom: 1rem !important; + padding-bottom: 0.5rem !important; + border-bottom: 2px solid var(--ifnex-primary) !important; +} + +.ifnex-form-grid { + display: grid !important; + grid-template-columns: repeat(2, 1fr) !important; + gap: 1rem !important; +} + +.ifnex-form-grid .full { + grid-column: span 2 !important; +} + +.ifnex-field { + display: flex !important; + flex-direction: column !important; +} + +.ifnex-field label { + font-size: 0.875rem !important; + font-weight: 500 !important; + color: var(--ifnex-gray-700) !important; + margin-bottom: 0.375rem !important; +} + +.ifnex-field input, +.ifnex-field select, +.ifnex-field textarea { + padding: 0.625rem 0.875rem !important; + border: 1px solid var(--ifnex-gray-300) !important; + border-radius: var(--ifnex-radius-md) !important; + font-size: 0.875rem !important; + font-family: var(--ifnex-font-family) !important; + transition: all 0.2s ease !important; +} + +.ifnex-field input:focus, +.ifnex-field select:focus, +.ifnex-field textarea:focus { + outline: none !important; + border-color: var(--ifnex-primary) !important; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1) !important; +} + +.ifnex-error-box { + padding: 0.75rem 1rem !important; + background: var(--ifnex-danger-light) !important; + border: 1px solid var(--ifnex-danger) !important; + border-radius: var(--ifnex-radius-md) !important; + color: var(--ifnex-danger) !important; + font-size: 0.875rem !important; + margin-bottom: 1rem !important; +} + +.ifnex-success-box { + padding: 0.75rem 1rem !important; + background: var(--ifnex-success-light) !important; + border: 1px solid var(--ifnex-success) !important; + border-radius: var(--ifnex-radius-md) !important; + color: var(--ifnex-success) !important; + font-size: 0.875rem !important; + margin-bottom: 1rem !important; +} + +.ifnex-warning-box { + padding: 0.75rem 1rem !important; + background: var(--ifnex-warning-light) !important; + border: 1px solid var(--ifnex-warning) !important; + border-radius: var(--ifnex-radius-md) !important; + color: #92400e !important; + font-size: 0.875rem !important; + margin-bottom: 1rem !important; +} diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js index 774b70f..b7fcca3 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js @@ -731,7 +731,9 @@ jQuery(document).ready(function($) { btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش'); if (res.success) { var orderId = res.data.data.id || res.data.id; - window.location.href = ifnex_ajax.orders_url + 'order-payment/?order_id=' + orderId; + // با فلوی جدید، کاربر به صفحه جزئیات سفارش هدایت می‌شود + // (چون پرداخت فقط بعد از تأیید کارمند امکان‌پذیر است) + window.location.href = ifnex_ajax.orders_url + 'order-detail/?order_id=' + orderId; } else { $('#ifnex-submit-error').text(res.data || 'خطا در ثبت سفارش').show(); } diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php index bb1239b..3b13d7c 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php @@ -231,18 +231,41 @@ function ifnex_user_profile_shortcode($atts) { -
-

💰 کیف پول

-
- - ریال - - - مسدود - - فعال +
+

💰 وضعیت مالی

+ + +
+
مانده حساب (موجودی منهای بدهی‌ها)
+
+ ریال +
+ 0): ?> +
+ ⚠️ بدهی در انتظار پرداخت: ریال +
+ + +
+
+
موجودی کیف پول
+
+ ریال +
+
+
+
وضعیت
+
+ + 🔴 مسدود + + 🟢 فعال + +
+
+
@@ -256,7 +279,11 @@ function ifnex_user_profile_shortcode($atts) {
کل سفارشات
-
+
+
در انتظار تأیید
+
+
+
در انتظار پرداخت
@@ -405,7 +432,7 @@ function ifnex_orders_list_shortcode($atts) { - + 💳 پرداخت @@ -695,11 +722,18 @@ function ifnex_order_payment_shortcode($atts) { $data = $order['data']; - // بررسی وضعیت - if ($data['status']['value'] !== 'pending_payment') { - return '
-

✅ این سفارش قبلاً پرداخت شده است

-

وضعیت: ' . esc_html($data['status']['label']) . '

+ // بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند) + if ($data['status']['value'] !== 'approved') { + $message = 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.'; + if ($data['status']['value'] === 'pending_approval') { + $message = 'این سفارش در انتظار تأیید کارمند است. پس از تأیید، گزینه پرداخت فعال خواهد شد.'; + } elseif ($data['status']['is_paid']) { + $message = 'این سفارش قبلاً پرداخت شده است.'; + } + + return '
'; } @@ -938,7 +972,7 @@ function ifnex_order_detail_shortcode($atts) { ← بازگشت به سفارشات - + 💳 پرداخت diff --git a/04_Laravel/app/Enums/ShipmentStatus.php b/04_Laravel/app/Enums/ShipmentStatus.php index e4b9355..2149b32 100644 --- a/04_Laravel/app/Enums/ShipmentStatus.php +++ b/04_Laravel/app/Enums/ShipmentStatus.php @@ -4,6 +4,8 @@ namespace App\Enums; enum ShipmentStatus: string { + case PendingApproval = 'pending_approval'; + case Approved = 'approved'; case PendingPayment = 'pending_payment'; case Cancelled = 'cancelled'; case Processed = 'processed'; @@ -18,15 +20,17 @@ enum ShipmentStatus: string public function label(): string { return match ($this) { + self::PendingApproval => 'در انتظار تأیید', + self::Approved => 'تأیید شده - در انتظار پرداخت', self::PendingPayment => 'در انتظار پرداخت', self::Cancelled => 'لغو شده', - self::Processed => 'Processed', - self::PickedUp => 'Picked Up', - self::InTransit => 'In Transit', - self::OutForDelivery => 'Out For Delivery', - self::Failed => 'Failed', - self::Delivered => 'Delivered', - self::Returned => 'Returned', + self::Processed => 'در حال پردازش', + self::PickedUp => 'تحویل به حمل‌کننده', + self::InTransit => 'در حال حمل', + self::OutForDelivery => 'آماده تحویل', + self::Failed => 'تحویل ناموفق', + self::Delivered => 'تحویل شده', + self::Returned => 'برگشتی', self::Archived => 'آرشیو', }; } @@ -34,6 +38,8 @@ enum ShipmentStatus: string public function color(): string { return match ($this) { + self::PendingApproval => 'warning', + self::Approved => 'info', self::PendingPayment => 'warning', self::Cancelled => 'danger', self::Processed => 'gray', @@ -49,11 +55,37 @@ enum ShipmentStatus: string public function isPaid(): bool { - return $this !== self::PendingPayment; + return !in_array($this, [ + self::PendingApproval, + self::PendingPayment, + ]); } public function canBeCancelledByCustomer(): bool { - return $this === self::PendingPayment; + return in_array($this, [ + self::PendingApproval, + self::PendingPayment, + ]); } -} \ No newline at end of file + + public function isApproved(): bool + { + return $this === self::Approved; + } + + public function isPendingApproval(): bool + { + return $this === self::PendingApproval; + } + + public function canBeApprovedByStaff(): bool + { + return $this === self::PendingApproval; + } + + public function canBePaid(): bool + { + return $this === self::Approved; + } +} diff --git a/04_Laravel/app/Filament/Pages/BulkTrackingImport.php b/04_Laravel/app/Filament/Pages/BulkTrackingImport.php new file mode 100644 index 0000000..d0ae96d --- /dev/null +++ b/04_Laravel/app/Filament/Pages/BulkTrackingImport.php @@ -0,0 +1,198 @@ +form->fill([ + 'csv_file' => null, + 'status' => 'in_transit', + 'description' => '', + ]); + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\Section::make('فایل CSV') + ->schema([ + Forms\Components\FileUpload::make('csv_file') + ->label('فایل CSV') + ->acceptedFileTypes(['text/csv', 'text/plain']) + ->maxSize(10240) // 10MB + ->required() + ->helperText('فرمت: awb_no,status,description (هر سطر یک سفارش)'), + ]), + + Forms\Components\Section::make('تنظیمات') + ->schema([ + Forms\Components\Select::make('status') + ->label('وضعیت جدید') + ->options(collect(ShipmentStatus::cases())->mapWithKeys(fn ($status) => [$status->value => $status->label()])->toArray()) + ->default('in_transit') + ->required(), + Forms\Components\TextInput::make('description') + ->label('توضیحات پیش‌فرض') + ->placeholder('مثال: به‌روزرسانی گروهی وضعیت ترکینگ') + ->default('ایمپورت گروهی وضعیت'), + ]), + + Forms\Components\Section::make('پیش‌نمایش') + ->schema([ + Forms\Components\Placeholder::make('preview') + ->label('پیش‌نمایش فایل') + ->content(fn () => $this->getPreviewContent()), + ]) + ->collapsible(), + ]) + ->statePath('data'); + } + + private function getPreviewContent(): string + { + $file = $this->data['csv_file'] ?? null; + + if (!$file) { + return 'فایلی انتخاب نشده است. لطفاً فایل CSV را آپلود کنید.'; + } + + try { + $path = is_array($file) ? ($file['path'] ?? reset($file)) : $file; + $content = file_get_contents($path); + $lines = explode("\n", trim($content)); + + $preview = "
"; + $preview .= "خط اول (هدر): " . e($lines[0] ?? '') . "
"; + $preview .= "تعداد خطوط: " . count($lines) . "
"; + + if (isset($lines[1])) { + $preview .= "
نمونه (خط دوم): " . e($lines[1]); + } + + $preview .= "
"; + return $preview; + } catch (\Exception $e) { + return 'خطا در خواندن فایل: ' . $e->getMessage(); + } + } + + public function submit(): void + { + $data = $this->form->getState(); + + $file = $data['csv_file']; + $path = is_array($file) ? ($file['path'] ?? reset($file)) : $file; + $content = file_get_contents($path); + $lines = explode("\n", trim($content)); + + // حذف خط اول (هدر) + $dataLines = array_slice($lines, 1); + + $successCount = 0; + $errorCount = 0; + $errors = []; + + DB::beginTransaction(); + + try { + foreach ($dataLines as $lineNumber => $line) { + $line = trim($line); + if (empty($line)) continue; + + $parts = str_getcsv($line); + + if (count($parts) < 1) { + $errors[] = "خط " . ($lineNumber + 2) . ": فرمت نادرست"; + $errorCount++; + continue; + } + + $awbNo = $parts[0]; + $status = $parts[1] ?? $data['status']; + $description = $parts[2] ?? $data['description']; + + // بررسی وجود سفارش + $shipment = Shipment::where('awb_no', $awbNo)->first(); + + if (!$shipment) { + $errors[] = "خط " . ($lineNumber + 2) . ": سفارش {$awbNo} یافت نشد"; + $errorCount++; + continue; + } + + // به‌روزرسانی وضعیت + $shipment->update([ + 'status' => $status, + 'tracking_description' => $description, + ]); + + // ثبت تاریخچه + DB::table('tracking_histories')->insert([ + 'shipment_id' => $shipment->id, + 'status' => $status, + 'description' => $description, + 'location' => 'سیستم ایمپورت گروهی', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $successCount++; + } + + DB::commit(); + + Notification::make() + ->title('ایمپورت با موفقیت انجام شد') + ->body("{$successCount} سفارش با موفقیت به‌روزرسانی شد. {$errorCount} خطا رخ داد.") + ->success() + ->send(); + + if (!empty($errors)) { + Notification::make() + ->title('خطاها') + ->body(implode("\n", array_slice($errors, 0, 10))) + ->warning() + ->send(); + } + + $this->form->fill([ + 'csv_file' => null, + 'status' => 'in_transit', + 'description' => '', + ]); + + } catch (\Exception $e) { + DB::rollBack(); + + Notification::make() + ->title('خطا در ایمپورت') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } +} diff --git a/04_Laravel/app/Filament/Resources/AuditLogResource.php b/04_Laravel/app/Filament/Resources/AuditLogResource.php new file mode 100644 index 0000000..4301966 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/AuditLogResource.php @@ -0,0 +1,174 @@ +schema([ + Forms\Components\Section::make('اطلاعات لاگ') + ->schema([ + Forms\Components\Placeholder::make('event') + ->label('نوع رویداد') + ->content(fn ($record) => match($record->event) { + 'created' => '🟢 ایجاد', + 'updated' => '🟡 به‌روزرسانی', + 'deleted' => '🔴 حذف', + default => $record->event, + }), + Forms\Components\Placeholder::make('user') + ->label('کاربر') + ->content(fn ($record) => $record->user?->name ?? 'سیستم'), + Forms\Components\Placeholder::make('auditable') + ->label('رکورد') + ->content(fn ($record) => $record->auditable_type . ' #' . $record->auditable_id), + Forms\Components\Placeholder::make('created_at') + ->label('تاریخ و زمان') + ->content(fn ($record) => $record->created_at->format('Y-m-d H:i:s')), + ])->columns(2), + + Forms\Components\Section::make('تغییرات') + ->schema([ + Forms\Components\Placeholder::make('old_values') + ->label('مقادیر قبلی') + ->content(fn ($record) => $record->old_values ? json_encode($record->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '-'), + Forms\Components\Placeholder::make('new_values') + ->label('مقادیر جدید') + ->content(fn ($record) => $record->new_values ? json_encode($record->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '-'), + ])->columns(2), + + Forms\Components\Section::make('اطلاعات فنی') + ->schema([ + Forms\Components\Placeholder::make('description') + ->label('توضیحات') + ->content(fn ($record) => $record->description ?? '-'), + Forms\Components\Placeholder::make('ip_address') + ->label('آدرس IP') + ->content(fn ($record) => $record->ip_address ?? '-'), + Forms\Components\Placeholder::make('user_agent') + ->label('مرورگر') + ->content(fn ($record) => $record->user_agent ?? '-'), + ])->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('شناسه') + ->sortable(), + Tables\Columns\TextColumn::make('user.name') + ->label('کاربر') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('event') + ->label('رویداد') + ->formatStateUsing(fn ($state) => match($state) { + 'created' => '🟢 ایجاد', + 'updated' => '🟡 به‌روزرسانی', + 'deleted' => '🔴 حذف', + default => $state, + }) + ->sortable(), + Tables\Columns\TextColumn::make('auditable_type') + ->label('نوع مدل') + ->formatStateUsing(fn ($state) => class_basename($state)) + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('auditable_id') + ->label('شناسه رکورد') + ->sortable(), + Tables\Columns\TextColumn::make('description') + ->label('توضیحات') + ->limit(50) + ->wrap(), + Tables\Columns\TextColumn::make('created_at') + ->label('تاریخ') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('event') + ->label('نوع رویداد') + ->options([ + 'created' => 'ایجاد', + 'updated' => 'به‌روزرسانی', + 'deleted' => 'حذف', + ]), + Tables\Filters\SelectFilter::make('user_id') + ->label('کاربر') + ->relationship('user', 'name') + ->searchable(), + Tables\Filters\SelectFilter::make('auditable_type') + ->label('نوع مدل') + ->options([ + 'App\Models\Shipment' => 'سفارش', + 'App\Models\User' => 'کاربر', + 'App\Models\Wallet' => 'کیف پول', + 'App\Models\WalletTransaction' => 'تراکنش کیف پول', + 'App\Models\ShipmentChecklist' => 'چک‌لیست', + ]), + Tables\Filters\Filter::make('created_at') + ->label('تاریخ ایجاد') + ->form([ + Forms\Components\DatePicker::make('created_from') + ->label('از تاریخ'), + Forms\Components\DatePicker::make('created_until') + ->label('تا تاریخ'), + ]) + ->query(function ($query, array $data): Builder { + return $query + ->when($data['created_from'], fn ($query, $date) => $query->where('created_at', '>=', $date)) + ->when($data['created_until'], fn ($query, $date) => $query->where('created_at', '<=', $date)); + }), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAuditLogs::route('/'), + 'edit' => Pages\EditAuditLog::route('/{record}/edit'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return static::getModel()::where('created_at', '>=', now()->subDays(7))->count(); + } +} diff --git a/04_Laravel/app/Filament/Resources/AuditLogResource/Pages/EditAuditLog.php b/04_Laravel/app/Filament/Resources/AuditLogResource/Pages/EditAuditLog.php new file mode 100644 index 0000000..803344f --- /dev/null +++ b/04_Laravel/app/Filament/Resources/AuditLogResource/Pages/EditAuditLog.php @@ -0,0 +1,11 @@ +schema([ + Forms\Components\Section::make('اطلاعات تعهدنامه') + ->schema([ + Forms\Components\TextInput::make('title') + ->required() + ->maxLength(255) + ->label('عنوان'), + Forms\Components\Textarea::make('description') + ->nullable() + ->rows(3) + ->label('توضیحات'), + Forms\Components\Select::make('direction') + ->options([ + 'export' => 'صادرات', + 'import' => 'واردات', + 'both' => 'هر دو', + ]) + ->default('both') + ->required() + ->label('جهت ارسال'), + Forms\Components\Toggle::make('is_active') + ->default(true) + ->label('فعال'), + Forms\Components\TextInput::make('sort_order') + ->numeric() + ->default(0) + ->label('ترتیب نمایش'), + ])->columns(2), + + Forms\Components\Section::make('فایل') + ->schema([ + FileUpload::make('file_path') + ->label('فایل تعهدنامه') + ->disk('public') + ->directory('commitment-forms') + ->acceptedFileTypes(['application/pdf', 'image/png', 'image/jpeg']) + ->maxSize(10240) // 10MB + ->required() + ->columnSpanFull() + ->extraAttributes(['class' => 'w-full']) + ->getUploadedFileNameForStorageUsing(function ($file): string { + $extension = $file->getClientOriginalExtension(); + $filename = time() . '_' . uniqid() . '.' . $extension; + return $filename; + }), + Forms\Components\Placeholder::make('file_info') + ->label('اطلاعات فایل') + ->content(function ($record) { + if (!$record) return 'فایلی آپلود نشده'; + + $type = strtoupper(pathinfo($record->file_path, PATHINFO_EXTENSION)); + $size = $record->formatted_size; + + return "فرمت: {$type} | حجم: {$size}"; + }), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('title') + ->searchable() + ->label('عنوان'), + Tables\Columns\TextColumn::make('direction') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'export' => 'success', + 'import' => 'info', + 'both' => 'warning', + }) + ->formatStateUsing(fn (string $state): string => match ($state) { + 'export' => 'صادرات', + 'import' => 'واردات', + 'both' => 'هر دو', + }) + ->label('جهت'), + Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->label('فعال'), + Tables\Columns\TextColumn::make('sort_order') + ->sortable() + ->label('ترتیب'), + Tables\Columns\TextColumn::make('uploader.name') + ->label('آپلود کننده'), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->label('تاریخ ایجاد'), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('direction') + ->options([ + 'export' => 'صادرات', + 'import' => 'واردات', + 'both' => 'هر دو', + ]), + Tables\Filters\TernaryFilter::make('is_active') + ->label('فعال'), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + Tables\Actions\Action::make('download') + ->label('دانلود') + ->icon('heroicon-o-arrow-down-tray') + ->color('info') + ->url(fn ($record) => $record->file_url) + ->openUrlInNewTab(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCommitmentForms::route('/'), + 'create' => Pages\CreateCommitmentForm::route('/create'), + 'edit' => Pages\EditCommitmentForm::route('/{record}/edit'), + ]; + } +} diff --git a/04_Laravel/app/Filament/Resources/CommitmentFormResource/Pages/CreateCommitmentForm.php b/04_Laravel/app/Filament/Resources/CommitmentFormResource/Pages/CreateCommitmentForm.php new file mode 100644 index 0000000..1891762 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/CommitmentFormResource/Pages/CreateCommitmentForm.php @@ -0,0 +1,17 @@ +id(); + return $data; + } +} diff --git a/04_Laravel/app/Filament/Resources/CommitmentFormResource/Pages/EditCommitmentForm.php b/04_Laravel/app/Filament/Resources/CommitmentFormResource/Pages/EditCommitmentForm.php new file mode 100644 index 0000000..e6b7447 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/CommitmentFormResource/Pages/EditCommitmentForm.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\Section::make('اطلاعات مشتری') + ->schema([ + Forms\Components\TextInput::make('name') + ->label('نام') + ->disabled(), + Forms\Components\TextInput::make('email') + ->label('ایمیل') + ->disabled(), + Forms\Components\TextInput::make('phone') + ->label('موبایل') + ->disabled(), + ])->columns(3), + + Forms\Components\Section::make('اطلاعات اعتبار') + ->schema([ + Forms\Components\TextInput::make('credit_limit') + ->label('سقف اعتبار (ریال)') + ->numeric() + ->default(0) + ->minValue(0) + ->required(), + Forms\Components\TextInput::make('credit_used') + ->label('مبلغ استفاده شده (ریال)') + ->numeric() + ->default(0) + ->disabled(), + Forms\Components\TextInput::make('available_credit') + ->label('اعتبار باقیمانده (ریال)') + ->numeric() + ->disabled() + ->dehydrated(false), + ])->columns(3), + + Forms\Components\Section::make('تاریخچه تراکنش‌ها') + ->schema([ + Forms\Components\Placeholder::make('transactions_history') + ->label('تراکنش‌های اخیر') + ->content(function ($record) { + if (!$record) return '-'; + + $transactions = DB::table('wallet_transactions') + ->where('user_id', $record->id) + ->orderBy('created_at', 'desc') + ->take(5) + ->get(); + + if ($transactions->isEmpty()) return 'تراکنشی ثبت نشده'; + + $html = '
'; + foreach ($transactions as $tx) { + $type = match($tx->type) { + 'credit_add' => '➕ افزایش اعتبار', + 'credit_use' => '➖ استفاده از اعتبار', + default => $tx->type, + }; + $html .= '
' . $type . ': ' . number_format($tx->amount) . ' ریال - ' . $tx->description . '
'; + } + $html .= '
'; + return $html; + }), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('شناسه') + ->sortable(), + Tables\Columns\TextColumn::make('name') + ->label('نام') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('email') + ->label('ایمیل') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('phone') + ->label('موبایل') + ->searchable(), + Tables\Columns\TextColumn::make('credit_limit') + ->label('سقف اعتبار') + ->numeric() + ->formatStateUsing(fn ($state) => number_format($state) . ' ریال') + ->sortable(), + Tables\Columns\TextColumn::make('credit_used') + ->label('استفاده شده') + ->numeric() + ->formatStateUsing(fn ($state) => number_format($state) . ' ریال') + ->sortable(), + Tables\Columns\TextColumn::make('available_credit') + ->label('باقیمانده') + ->numeric() + ->formatStateUsing(fn ($state) => number_format($state) . ' ریال') + ->color(fn ($state) => $state > 0 ? 'success' : 'danger') + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('has_credit') + ->label('وضعیت اعتبار') + ->options([ + 'has_limit' => 'دارای سقف اعتبار', + 'no_limit' => 'بدون سقف اعتبار', + 'has_used' => 'استفاده شده', + 'has_available' => 'باقیمانده', + ]) + ->query(function ($query, $filter) { + match ($filter->getState()) { + 'has_limit' => $query->where('credit_limit', '>', 0), + 'no_limit' => $query->where('credit_limit', 0), + 'has_used' => $query->where('credit_used', '>', 0), + 'has_available' => $query->whereRaw('credit_limit - credit_used > 0'), + }; + }), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\Action::make('add_credit') + ->label('افزایش اعتبار') + ->icon('heroicon-o-plus-circle') + ->color('success') + ->form([ + Forms\Components\TextInput::make('amount') + ->label('مبلغ (ریال)') + ->numeric() + ->required() + ->minValue(1), + Forms\Components\TextInput::make('description') + ->label('توضیحات') + ->required(), + ]) + ->action(function ($record, array $data): void { + DB::beginTransaction(); + try { + $record->credit_limit += $data['amount']; + $record->save(); + + DB::table('wallet_transactions')->insert([ + 'user_id' => $record->id, + 'type' => 'credit_add', + 'amount' => $data['amount'], + 'description' => $data['description'], + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + }), + Tables\Actions\Action::make('reduce_credit') + ->label('کاهش اعتبار') + ->icon('heroicon-o-minus-circle') + ->color('danger') + ->form([ + Forms\Components\TextInput::make('amount') + ->label('مبلغ (ریال)') + ->numeric() + ->required() + ->minValue(1), + Forms\Components\TextInput::make('description') + ->label('توضیحات') + ->required(), + ]) + ->action(function ($record, array $data): void { + DB::beginTransaction(); + try { + $record->credit_limit -= $data['amount']; + $record->save(); + + DB::table('wallet_transactions')->insert([ + 'user_id' => $record->id, + 'type' => 'credit_reduce', + 'amount' => $data['amount'], + 'description' => $data['description'], + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCustomerCredits::route('/'), + 'edit' => Pages\EditCustomerCredit::route('/{record}/edit'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return static::getModel()::where('credit_limit', '>', 0)->count(); + } +} diff --git a/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/EditCustomerCredit.php b/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/EditCustomerCredit.php new file mode 100644 index 0000000..92d9d8f --- /dev/null +++ b/04_Laravel/app/Filament/Resources/CustomerCreditResource/Pages/EditCustomerCredit.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\Section::make('اطلاعات سفارش') + ->schema([ + Forms\Components\Select::make('shipment_id') + ->label('سفارش') + ->relationship('shipment', 'awb_no') + ->searchable() + ->required() + ->getOptionLabelFromRecordUsing(fn ($record) => "{$record->awb_no} - {$record->status->label()}"), + Forms\Components\Select::make('user_id') + ->label('کارمند') + ->relationship('user', 'name') + ->searchable() + ->required(), + ])->columns(2), + + Forms\Components\Section::make('اطلاعات چک‌لیست') + ->schema([ + Forms\Components\Select::make('status') + ->label('وضعیت فعلی') + ->options(collect(ShipmentStatus::cases())->mapWithKeys(fn ($status) => [$status->value => $status->label()])->toArray()) + ->required(), + Forms\Components\Toggle::make('is_completed') + ->label('تکمیل شده') + ->default(false), + Forms\Components\Textarea::make('notes') + ->label('یادداشت‌ها') + ->rows(3), + Forms\Components\DateTimePicker::make('completed_at') + ->label('زمان تکمیل') + ->disabled(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('شناسه') + ->sortable(), + Tables\Columns\TextColumn::make('shipment.awb_no') + ->label('شماره سفارش') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('user.name') + ->label('کارمند') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('status') + ->label('وضعیت') + ->formatStateUsing(fn ($state) => match($state) { + 'pending_approval' => 'در انتظار تأیید', + 'approved' => 'تأیید شده', + 'pending_payment' => 'در انتظار پرداخت', + 'paid' => 'پرداخت شده', + 'processed' => 'در حال پردازش', + 'picked_up' => 'جمع‌آوری شده', + 'in_transit' => 'در حال ارسال', + 'out_for_delivery' => 'آماده تحویل', + 'delivered' => 'تحویل شده', + 'cancelled' => 'لغو شده', + default => $state, + }) + ->badge() + ->color(fn ($state) => match($state) { + 'pending_approval' => 'warning', + 'approved' => 'info', + 'pending_payment' => 'warning', + 'paid' => 'success', + 'processed' => 'info', + 'picked_up' => 'info', + 'in_transit' => 'primary', + 'out_for_delivery' => 'success', + 'delivered' => 'success', + 'cancelled' => 'danger', + default => 'gray', + }), + Tables\Columns\IconColumn::make('is_completed') + ->label('تکمیل') + ->boolean(), + Tables\Columns\TextColumn::make('notes') + ->label('یادداشت') + ->limit(50) + ->wrap(), + Tables\Columns\TextColumn::make('created_at') + ->label('تاریخ ایجاد') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('completed_at') + ->label('زمان تکمیل') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('is_completed') + ->label('وضعیت تکمیل') + ->options([ + 'completed' => 'تکمیل شده', + 'pending' => 'در انتظار', + ]) + ->query(function ($query, $filter) { + match ($filter->getState()) { + 'completed' => $query->where('is_completed', true), + 'pending' => $query->where('is_completed', false), + }; + }), + Tables\Filters\SelectFilter::make('status') + ->label('وضعیت سفارش') + ->options(collect(ShipmentStatus::cases())->mapWithKeys(fn ($status) => [$status->value => $status->label()])->toArray()), + Tables\Filters\SelectFilter::make('user_id') + ->label('کارمند') + ->relationship('user', 'name') + ->searchable(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\Action::make('complete') + ->label('تکمیل') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->modalHeading('تأیید تکمیل') + ->modalDescription('آیا از تکمیل این چک‌لیست اطمینان دارید؟') + ->action(fn ($record) => $record->complete()) + ->visible(fn ($record) => !$record->is_completed), + Tables\Actions\Action::make('add_note') + ->label('افزودن یادداشت') + ->icon('heroicon-o-chat-bubble-bottom-center-text') + ->color('info') + ->form([ + Forms\Components\Textarea::make('note') + ->label('یادداشت جدید') + ->required(), + ]) + ->action(function ($record, array $data): void { + $existingNotes = $record->notes ? $record->notes . "\n\n" : ''; + $record->update([ + 'notes' => $existingNotes . "[" . now()->format('Y-m-d H:i') . "] " . $data['note'], + ]); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListShipmentChecklists::route('/'), + 'create' => Pages\CreateShipmentChecklist::route('/create'), + 'edit' => Pages\EditShipmentChecklist::route('/{record}/edit'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return static::getModel()::where('is_completed', false)->count(); + } +} diff --git a/04_Laravel/app/Filament/Resources/ShipmentChecklistResource/Pages/CreateShipmentChecklist.php b/04_Laravel/app/Filament/Resources/ShipmentChecklistResource/Pages/CreateShipmentChecklist.php new file mode 100644 index 0000000..4cca41f --- /dev/null +++ b/04_Laravel/app/Filament/Resources/ShipmentChecklistResource/Pages/CreateShipmentChecklist.php @@ -0,0 +1,11 @@ +label('Cash on Delivery'), ])->columns(4), + Forms\Components\Section::make('Import Invoice Fields (Service Sales Invoice)') + ->schema([ + Forms\Components\TextInput::make('brand_fee') + ->numeric() + ->default(0) + ->prefix('ریال') + ->label('Brand Fee'), + Forms\Components\TextInput::make('report_fee') + ->numeric() + ->default(0) + ->prefix('ریال') + ->label('Report Fee'), + Forms\Components\TextInput::make('customs_clearance_cost') + ->numeric() + ->default(0) + ->prefix('ریال') + ->label('Customs Clearance Cost'), + Forms\Components\TextInput::make('order_registration_fee') + ->numeric() + ->default(0) + ->prefix('ریال') + ->label('Order Registration Fee'), + Forms\Components\TextInput::make('other_clearance_charges') + ->numeric() + ->default(0) + ->prefix('ریال') + ->label('Other Clearance Charges'), + Forms\Components\TextInput::make('exchange_rate') + ->numeric() + ->default(0) + ->label('Exchange Rate'), + Forms\Components\TextInput::make('goods_nature') + ->nullable() + ->label('Goods Nature'), + Forms\Components\Select::make('invoice_currency') + ->options([ + 'USD' => 'USD', + 'EUR' => 'EUR', + 'AED' => 'AED', + 'IRR' => 'IRR', + ]) + ->default('USD') + ->label('Invoice Currency'), + ])->columns(4), + Forms\Components\Section::make('Sender Info') ->schema([ Forms\Components\TextInput::make('sender_name') @@ -406,6 +451,32 @@ class ShipmentResource extends Resource ->actions([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), + Action::make('approve') + ->label('تأیید') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->modalHeading('تأیید سفارش') + ->modalDescription('آیا از تأیید این سفارش اطمینان دارید؟ پس از تأیید، مشتری می‌تواند پرداخت را انجام دهد.') + ->modalSubmitActionLabel('بله، تأیید کن') + ->visible(fn ($record) => $record->status === \App\Enums\ShipmentStatus::PendingApproval) + ->action(function ($record) { + $oldStatus = $record->status; + $record->update(['status' => \App\Enums\ShipmentStatus::Approved]); + + \App\Models\ShipmentStatusHistory::create([ + 'shipment_id' => $record->id, + 'from_status' => $oldStatus->value, + 'to_status' => \App\Enums\ShipmentStatus::Approved->value, + 'reason' => 'تأیید توسط مدیر از پنل Filament', + 'changed_by' => auth()->id(), + ]); + + \Filament\Notifications\Notification::make() + ->title('سفارش با موفقیت تأیید شد') + ->success() + ->send(); + }), ]) ->headerActions([ Action::make('exportCsv') diff --git a/04_Laravel/app/Http/Controllers/Api/CommitmentFormController.php b/04_Laravel/app/Http/Controllers/Api/CommitmentFormController.php new file mode 100644 index 0000000..aa33d99 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/CommitmentFormController.php @@ -0,0 +1,66 @@ +where('is_active', true) + ->orderBy('sort_order') + ->orderBy('created_at', 'desc') + ->get() + ->map(fn ($form) => [ + 'id' => $form->id, + 'title' => $form->title, + 'description' => $form->description, + 'file_url' => $form->file_url, + 'file_type' => strtoupper(pathinfo($form->file_path, PATHINFO_EXTENSION)), + 'direction' => $form->direction, + ]); + + return response()->json([ + 'success' => true, + 'data' => $forms, + ]); + } + + /** + * دریافت تعهدنامه‌ها بر اساس جهت ارسال + * GET /api/v1/commitment-forms/{direction} + */ + public function byDirection(string $direction): JsonResponse + { + $forms = CommitmentForm::query() + ->where('is_active', true) + ->where(function ($query) use ($direction) { + $query->where('direction', 'both') + ->orWhere('direction', $direction); + }) + ->orderBy('sort_order') + ->orderBy('created_at', 'desc') + ->get() + ->map(fn ($form) => [ + 'id' => $form->id, + 'title' => $form->title, + 'description' => $form->description, + 'file_url' => $form->file_url, + 'file_type' => strtoupper(pathinfo($form->file_path, PATHINFO_EXTENSION)), + 'direction' => $form->direction, + ]); + + return response()->json([ + 'success' => true, + 'data' => $forms, + ]); + } +} diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php index f12b373..b3b67b2 100644 --- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php +++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php @@ -212,7 +212,7 @@ class CustomerOrderController extends Controller 'awb_no' => $awbNo, 'direction' => $validated['direction'], 'type' => $validated['type'], - 'status' => ShipmentStatus::PendingPayment, + 'status' => ShipmentStatus::PendingApproval, // وزن 'weight' => $validated['weight'], @@ -307,7 +307,7 @@ class CustomerOrderController extends Controller return response()->json([ 'success' => true, - 'message' => 'سفارش شما با موفقیت ثبت شد. لطفاً برای تکمیل، پرداخت را انجام دهید.', + 'message' => 'سفارش شما با موفقیت ثبت شد. پس از تأیید کارمند، گزینه پرداخت برای شما فعال خواهد شد.', 'data' => $this->formatShipment($shipment->load(['fromCountry', 'toCountry', 'items'])), 'pricing' => $priceResult, ], 201); @@ -384,12 +384,25 @@ class CustomerOrderController extends Controller $user = Auth::user(); $user->load(['wallet']); + // محاسبه مجموع سفارشات در انتظار پرداخت (بدهی کاربر) + $pending_payments_total = Shipment::where('user_id', $user->id) + ->where('status', ShipmentStatus::Approved) + ->sum('total_fee'); + + // مانده حساب = موجودی کیف پول - بدهی‌ها + $wallet_balance = $user->wallet ? $user->wallet->balance : 0; + $account_balance = $wallet_balance - $pending_payments_total; + // آمار سفارشات $stats = [ 'total_orders' => Shipment::where('user_id', $user->id)->count(), - 'pending_orders' => Shipment::where('user_id', $user->id) - ->where('status', ShipmentStatus::PendingPayment) + 'pending_approval' => Shipment::where('user_id', $user->id) + ->where('status', ShipmentStatus::PendingApproval) ->count(), + 'pending_payment' => Shipment::where('user_id', $user->id) + ->where('status', ShipmentStatus::Approved) + ->count(), + 'pending_payment_total' => $pending_payments_total, 'active_orders' => Shipment::where('user_id', $user->id) ->whereIn('status', [ ShipmentStatus::Processed, @@ -402,7 +415,8 @@ class CustomerOrderController extends Controller ->where('status', ShipmentStatus::Delivered) ->count(), 'total_spent' => Shipment::where('user_id', $user->id) - ->where('status', '!=', ShipmentStatus::PendingPayment) + ->where('status', '!=', ShipmentStatus::PendingApproval) + ->where('status', '!=', ShipmentStatus::Approved) ->where('status', '!=', ShipmentStatus::Cancelled) ->sum('total_fee'), ]; @@ -420,6 +434,8 @@ class CustomerOrderController extends Controller 'balance' => $user->wallet->balance, 'is_frozen' => $user->wallet->is_frozen, ] : null, + 'account_balance' => $account_balance, + 'pending_payments_total' => $pending_payments_total, 'stats' => $stats, ]); } @@ -457,6 +473,8 @@ class CustomerOrderController extends Controller 'color' => $shipment->status?->color(), 'is_paid' => $shipment->status?->isPaid(), 'can_cancel' => $shipment->status?->canBeCancelledByCustomer(), + 'can_pay' => $shipment->status?->canBePaid(), + 'is_approved' => $shipment->status?->isApproved(), ], 'from_country' => $shipment->fromCountry ? [ 'id' => $shipment->fromCountry->id, @@ -565,11 +583,11 @@ class CustomerOrderController extends Controller ], 403); } - // بررسی وضعیت - if ($shipment->status !== ShipmentStatus::PendingPayment) { + // بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند) + if ($shipment->status !== ShipmentStatus::Approved) { return response()->json([ 'success' => false, - 'message' => 'این سفارش در وضعیت قابل پرداخت نیست.', + 'message' => 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.', ], 400); } @@ -611,11 +629,11 @@ class CustomerOrderController extends Controller ], 403); } - // بررسی وضعیت - if ($shipment->status !== ShipmentStatus::PendingPayment) { + // بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند) + if ($shipment->status !== ShipmentStatus::Approved) { return response()->json([ 'success' => false, - 'message' => 'این سفارش در وضعیت قابل پرداخت نیست.', + 'message' => 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.', ], 400); } diff --git a/04_Laravel/app/Http/Controllers/Api/CustomerFinancialController.php b/04_Laravel/app/Http/Controllers/Api/CustomerFinancialController.php new file mode 100644 index 0000000..ada7202 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/CustomerFinancialController.php @@ -0,0 +1,135 @@ +validate([ + 'q' => ['required', 'string', 'min:2'], + ]); + + $query = User::query() + ->where(function ($q) use ($validated) { + $q->where('name', 'LIKE', "%{$validated['q']}%") + ->orWhere('email', 'LIKE', "%{$validated['q']}%") + ->orWhere('phone', 'LIKE', "%{$validated['q']}%"); + }) + ->with('wallet'); + + $customers = $query->take(20)->get()->map(function ($customer) { + return [ + 'id' => $customer->id, + 'name' => $customer->name, + 'email' => $customer->email, + 'phone' => $customer->phone, + 'wallet_balance' => $customer->wallet ? $customer->wallet->balance : 0, + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => $customers, + ]); + } + + /** + * دریافت وضعیت مالی کامل مشتری + * GET /api/v1/staff/customers/{customer}/financial-status + */ + public function financialStatus(User $customer): JsonResponse + { + // دریافت سفارشات در انتظار پرداخت (تأیید شده) + $pending_orders = Shipment::where('user_id', $customer->id) + ->where('status', ShipmentStatus::Approved) + ->with(['fromCountry', 'toCountry']) + ->orderBy('created_at', 'desc') + ->get(); + + // محاسبه بدهی به هر ارز + $debts_by_currency = $pending_orders->groupBy(function ($order) { + return $order->fromCountry?->iso_code ?? 'unknown'; + })->map(function ($orders, $currency) { + return [ + 'currency' => $currency, + 'total' => $orders->sum('total_fee'), + 'count' => $orders->count(), + ]; + }); + + // دریافت تراکنش‌های اخیر + $recent_transactions = WalletTransaction::where('user_id', $customer->id) + ->orderBy('created_at', 'desc') + ->take(10) + ->get() + ->map(fn ($tx) => [ + 'id' => $tx->id, + 'type' => $tx->type, + 'amount' => $tx->amount, + 'description' => $tx->description, + 'created_at' => $tx->created_at->format('Y-m-d H:i'), + ]); + + // دریافت سفارشات اخیر + $recent_orders = Shipment::where('user_id', $customer->id) + ->with(['fromCountry', 'toCountry']) + ->orderBy('created_at', 'desc') + ->take(10) + ->get() + ->map(fn ($order) => [ + 'id' => $order->id, + 'awb_no' => $order->awb_no, + 'status' => [ + 'value' => $order->status?->value, + 'label' => $order->status?->label(), + ], + 'direction' => $order->direction?->value, + 'total_fee' => $order->total_fee, + 'from_country' => $order->fromCountry?->name, + 'to_country' => $order->toCountry?->name, + 'created_at' => $order->created_at->format('Y-m-d H:i'), + ]); + + // محاسبه کل بدهی + $total_debt = $pending_orders->sum('total_fee'); + + // موجودی کیف پول + $wallet_balance = $customer->wallet ? $customer->wallet->balance : 0; + + // مانده حساب + $account_balance = $wallet_balance - $total_debt; + + return response()->json([ + 'success' => true, + 'customer' => [ + 'id' => $customer->id, + 'name' => $customer->name, + 'email' => $customer->email, + 'phone' => $customer->phone, + ], + 'financial' => [ + 'wallet_balance' => $wallet_balance, + 'total_debt' => $total_debt, + 'account_balance' => $account_balance, + 'debts_by_currency' => array_values($debts_by_currency->toArray()), + 'pending_orders_count' => $pending_orders->count(), + ], + 'recent_transactions' => $recent_transactions, + 'recent_orders' => $recent_orders, + ]); + } +} diff --git a/04_Laravel/app/Http/Controllers/Api/MobileVerificationController.php b/04_Laravel/app/Http/Controllers/Api/MobileVerificationController.php new file mode 100644 index 0000000..90e04cf --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/MobileVerificationController.php @@ -0,0 +1,104 @@ +smsService = $smsService; + } + + /** + * ارسال کد تأیید به شماره موبایل + * POST /api/v1/verify/send-code + */ + public function sendCode(Request $request): JsonResponse + { + $validated = $request->validate([ + 'phone' => ['required', 'string', 'regex:/^09[0-9]{9}$/'], + ]); + + $result = $this->smsService->sendVerificationCode($validated['phone']); + + if ($result['success']) { + return response()->json([ + 'success' => true, + 'message' => 'کد تأیید با موفقیت ارسال شد', + ]); + } + + return response()->json([ + 'success' => false, + 'message' => $result['message'], + ], 400); + } + + /** + * بررسی کد تأیید + * POST /api/v1/verify/check-code + */ + public function checkCode(Request $request): JsonResponse + { + $validated = $request->validate([ + 'phone' => ['required', 'string', 'regex:/^09[0-9]{9}$/'], + 'code' => ['required', 'string', 'size:5'], + ]); + + $isValid = $this->smsService->verifyCode($validated['phone'], $validated['code']); + + if ($isValid) { + // به‌روزرسانی شماره موبایل کاربر (اختیاری) + $user = Auth::user(); + if ($user && $user->phone !== $validated['phone']) { + $user->update(['phone' => $validated['phone']]); + } + + return response()->json([ + 'success' => true, + 'message' => 'کد تأیید صحیح است', + ]); + } + + return response()->json([ + 'success' => false, + 'message' => 'کد تأیید نادرست است یا منقضی شده است', + ], 400); + } + + /** + * ارسال پیامک به شماره موبایل (برای استفاده داخلی) + * POST /api/v1/verify/send-sms + */ + public function sendSms(Request $request): JsonResponse + { + $validated = $request->validate([ + 'phone' => ['required', 'string', 'regex:/^09[0-9]{9}$/'], + 'message' => ['required', 'string', 'max:500'], + ]); + + $result = $this->smsService->send($validated['phone'], $validated['message']); + + if ($result['success']) { + return response()->json([ + 'success' => true, + 'message' => 'پیامک با موفقیت ارسال شد', + 'cost' => $result['cost'] ?? 0, + ]); + } + + return response()->json([ + 'success' => false, + 'message' => $result['message'], + ], 400); + } +} diff --git a/04_Laravel/app/Http/Controllers/Api/StaffOrderController.php b/04_Laravel/app/Http/Controllers/Api/StaffOrderController.php new file mode 100644 index 0000000..ade6976 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/StaffOrderController.php @@ -0,0 +1,201 @@ +validate([ + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $shipments = Shipment::query() + ->where('status', ShipmentStatus::PendingApproval) + ->with(['fromCountry', 'toCountry', 'user']) + ->orderBy('created_at', 'asc') + ->paginate($validated['per_page'] ?? 15); + + return response()->json([ + 'success' => true, + 'data' => $shipments->map(function ($shipment) { + return $this->formatShipment($shipment); + }), + 'pagination' => [ + 'current_page' => $shipments->currentPage(), + 'last_page' => $shipments->lastPage(), + 'per_page' => $shipments->perPage(), + 'total' => $shipments->total(), + ], + ]); + } + + /** + * تأیید سفارش توسط کارمند + * POST /api/v1/staff/orders/{shipment}/approve + */ + public function approve(Shipment $shipment, Request $request): JsonResponse + { + $user = Auth::user(); + + // بررسی وضعیت + if ($shipment->status !== ShipmentStatus::PendingApproval) { + return response()->json([ + 'success' => false, + 'message' => 'این سفارش در وضعیت قابل تأیید نیست.', + ], 400); + } + + $validated = $request->validate([ + 'notes' => ['nullable', 'string', 'max:1000'], + ]); + + try { + DB::transaction(function () use ($shipment, $user, $validated) { + // تغییر وضعیت به Approved + $oldStatus = $shipment->status; + $shipment->update([ + 'status' => ShipmentStatus::Approved, + ]); + + // ثبت در تاریخچه تغییرات + ShipmentStatusHistory::create([ + 'shipment_id' => $shipment->id, + 'old_status' => $oldStatus->value, + 'new_status' => ShipmentStatus::Approved->value, + 'changed_by' => $user->id, + 'notes' => $validated['notes'] ?? 'تأیید توسط کارمند', + ]); + }); + + return response()->json([ + 'success' => true, + 'message' => 'سفارش با موفقیت تأیید شد. مشتری می‌تواند پرداخت را انجام دهد.', + 'data' => $this->formatShipment($shipment->fresh()->load(['fromCountry', 'toCountry'])), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'خطا در تأیید سفارش: ' . $e->getMessage(), + ], 500); + } + } + + /** + * رد سفارش توسط کارمند + * POST /api/v1/staff/orders/{shipment}/reject + */ + public function reject(Shipment $shipment, Request $request): JsonResponse + { + $user = Auth::user(); + + // بررسی وضعیت + if ($shipment->status !== ShipmentStatus::PendingApproval) { + return response()->json([ + 'success' => false, + 'message' => 'این سفارش در وضعیت قابل رد نیست.', + ], 400); + } + + $validated = $request->validate([ + 'reason' => ['required', 'string', 'max:1000'], + ]); + + try { + DB::transaction(function () use ($shipment, $user, $validated) { + // تغییر وضعیت به Cancelled + $oldStatus = $shipment->status; + $shipment->update([ + 'status' => ShipmentStatus::Cancelled, + ]); + + // ثبت در تاریخچه تغییرات + ShipmentStatusHistory::create([ + 'shipment_id' => $shipment->id, + 'old_status' => $oldStatus->value, + 'new_status' => ShipmentStatus::Cancelled->value, + 'changed_by' => $user->id, + 'notes' => 'رد شده: ' . $validated['reason'], + ]); + }); + + return response()->json([ + 'success' => true, + 'message' => 'سفارش رد شد.', + 'data' => $this->formatShipment($shipment->fresh()->load(['fromCountry', 'toCountry'])), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'خطا در رد سفارش: ' . $e->getMessage(), + ], 500); + } + } + + /** + * فرمت کردن Shipment برای خروجی API + */ + private function formatShipment(Shipment $shipment): array + { + return [ + 'id' => $shipment->id, + 'awb_no' => $shipment->awb_no, + 'direction' => $shipment->direction?->value, + 'type' => $shipment->type?->value, + 'status' => [ + 'value' => $shipment->status?->value, + 'label' => $shipment->status?->label(), + 'color' => $shipment->status?->color(), + ], + 'from_country' => $shipment->fromCountry ? [ + 'id' => $shipment->fromCountry->id, + 'name' => $shipment->fromCountry->name, + 'iso_code' => $shipment->fromCountry->iso_code, + ] : null, + 'to_country' => $shipment->toCountry ? [ + 'id' => $shipment->toCountry->id, + 'name' => $shipment->toCountry->name, + 'iso_code' => $shipment->toCountry->iso_code, + ] : null, + 'user' => $shipment->user ? [ + 'id' => $shipment->user->id, + 'name' => $shipment->user->name, + 'email' => $shipment->user->email, + ] : null, + 'weight' => $shipment->weight, + 'chargeable_weight' => $shipment->chargeable_weight, + 'total_fee' => $shipment->total_fee, + 'created_at' => $shipment->created_at->toIso8601String(), + 'created_at_jalali' => $this->toJalali($shipment->created_at), + ]; + } + + /** + * تبدیل تاریخ به شمسی + */ + private function toJalali($date): string + { + if (!$date) return '—'; + + try { + return \Morilog\Jalali\Jalalian::fromCarbon($date)->format('Y/m/d H:i'); + } catch (\Exception $e) { + return $date->format('Y-m-d H:i'); + } + } +} diff --git a/04_Laravel/app/Http/Controllers/ShipmentPdfController.php b/04_Laravel/app/Http/Controllers/ShipmentPdfController.php index dcf9d6f..621d23f 100644 --- a/04_Laravel/app/Http/Controllers/ShipmentPdfController.php +++ b/04_Laravel/app/Http/Controllers/ShipmentPdfController.php @@ -128,4 +128,56 @@ class ShipmentPdfController extends Controller return response('PDF generation failed: ' . $e->getMessage(), 500); } } + + /** + * تولید PDF فاکتور فروش خدمات (واردات) — مطابق Sheet ENG Invoice + */ + public function importInvoice(Shipment $shipment, Request $request) + { + try { + // دریافت پارامترها از درخواست + $options = $request->only([ + 'invoice_no', + 'invoice_date', + 'customs_office', + 'exchange_rate', + 'international_freight', + 'pick_up_fee', + 'brand_fee', + 'report_fee', + 'other_charges', + 'customs_clearance', + 'domestic_transport', + 'warehousing_fee', + 'order_registration_fee', + 'other_clearance_charges', + 'terms_and_conditions', + ]); + + // تبدیل exchange_rate به عدد + if (isset($options['exchange_rate'])) { + $options['exchange_rate'] = (float) $options['exchange_rate']; + } + + $content = $this->pdf->importInvoice($shipment, $options); + + Log::info('Import Invoice PDF generated successfully', [ + 'awb' => $shipment->awb_no, + 'options' => $options, + ]); + + return response($content, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="SERVICE-INVOICE-' . $shipment->awb_no . '.pdf"', + ]); + } catch (\Throwable $e) { + Log::error('Import Invoice PDF generation failed', [ + 'shipment_id' => $shipment->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response('PDF generation failed: ' . $e->getMessage(), 500); + } + } } diff --git a/04_Laravel/app/Models/AuditLog.php b/04_Laravel/app/Models/AuditLog.php new file mode 100644 index 0000000..8927b8b --- /dev/null +++ b/04_Laravel/app/Models/AuditLog.php @@ -0,0 +1,65 @@ + 'array', + 'new_values' => 'array', + ]; + + // ─── Relationships ─────────────────────────────── + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function auditable() + { + return $this->morphTo(); + } + + // ─── Helper Methods ────────────────────────────── + + public static function log( + string $event, + Model $model, + array $oldValues = [], + array $newValues = [], + ?string $description = null + ): self { + return static::create([ + 'user_id' => auth()->id(), + 'event' => $event, + 'auditable_type' => get_class($model), + 'auditable_id' => $model->getKey(), + 'old_values' => $oldValues, + 'new_values' => $newValues, + 'description' => $description, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + } +} diff --git a/04_Laravel/app/Models/CommitmentForm.php b/04_Laravel/app/Models/CommitmentForm.php new file mode 100644 index 0000000..800223f --- /dev/null +++ b/04_Laravel/app/Models/CommitmentForm.php @@ -0,0 +1,61 @@ + 'boolean', + 'sort_order' => 'integer', + 'file_size' => 'integer', + ]; + + /** + * کاربری که فایل را آپلود کرده + */ + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + /** + * آدرس کامل فایل + */ + public function getFileUrlAttribute(): string + { + return asset('storage/' . $this->file_path); + } + + /** + * فرمت حجم فایل + */ + public function getFormattedSizeAttribute(): string + { + $bytes = $this->file_size; + $units = ['B', 'KB', 'MB', 'GB']; + + for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, 2) . ' ' . $units[$i]; + } +} diff --git a/04_Laravel/app/Models/Shipment.php b/04_Laravel/app/Models/Shipment.php index 204e868..4055bf8 100644 --- a/04_Laravel/app/Models/Shipment.php +++ b/04_Laravel/app/Models/Shipment.php @@ -34,6 +34,16 @@ class Shipment extends Model 'net_dirham', 'net_rial', 'invoice_total_usd', + 'cod_amount', + // Import Invoice Fields + 'brand_fee', + 'report_fee', + 'customs_clearance_cost', + 'order_registration_fee', + 'other_clearance_charges', + 'exchange_rate', + 'goods_nature', + 'invoice_currency', // Sender 'sender_name', 'sender_company', @@ -55,6 +65,8 @@ class Shipment extends Model // Customs 'reason_for_export', 'content_description', + // User + 'user_id', ]; protected $casts = [ diff --git a/04_Laravel/app/Models/ShipmentChecklist.php b/04_Laravel/app/Models/ShipmentChecklist.php new file mode 100644 index 0000000..55e7b0f --- /dev/null +++ b/04_Laravel/app/Models/ShipmentChecklist.php @@ -0,0 +1,48 @@ + 'boolean', + 'completed_at' => 'datetime', + ]; + + // ─── Relationships ─────────────────────────────── + + public function shipment() + { + return $this->belongsTo(Shipment::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + // ─── Helper Methods ────────────────────────────── + + public function complete(): void + { + $this->update([ + 'is_completed' => true, + 'completed_at' => now(), + ]); + } +} diff --git a/04_Laravel/app/Models/User.php b/04_Laravel/app/Models/User.php index 8e46e64..93ca6fd 100644 --- a/04_Laravel/app/Models/User.php +++ b/04_Laravel/app/Models/User.php @@ -18,6 +18,8 @@ class User extends Authenticatable 'password', 'phone', 'role', // فیلد قدیمی - بعداً حذف می‌کنیم + 'credit_limit', // سقف اعتبار + 'credit_used', // مبلغ استفاده شده از اعتبار ]; protected $hidden = [ @@ -30,6 +32,8 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'credit_limit' => 'decimal:2', + 'credit_used' => 'decimal:2', ]; } @@ -62,6 +66,18 @@ class User extends Authenticatable return $this->hasRole('customer'); } + // ─── Credit Methods ────────────────────────────── + + public function getAvailableCreditAttribute(): float + { + return $this->credit_limit - $this->credit_used; + } + + public function hasAvailableCredit(float $amount): bool + { + return $this->available_credit >= $amount; + } + // ─── Filament Panel Access ─────────────────────── public function canAccessPanel(\Filament\Panel $panel): bool diff --git a/04_Laravel/app/Services/KavenegarSmsService.php b/04_Laravel/app/Services/KavenegarSmsService.php new file mode 100644 index 0000000..8f9c885 --- /dev/null +++ b/04_Laravel/app/Services/KavenegarSmsService.php @@ -0,0 +1,169 @@ +apiKey = config('services.kavenegar.api_key', ''); + $this->sender = config('services.kavenegar.sender', ''); + } + + /** + * ارسال کد تأیید به شماره موبایل + */ + public function sendVerificationCode(string $phone): array + { + $code = Str::random(5, '0123456789'); + $cacheKey = "sms_verify_{$phone}"; + + // ذخیره کد در cache به مدت ۱۰ دقیقه + Cache::put($cacheKey, $code, now()->addMinutes(10)); + + $message = "کد تأیید IFNEX: {$code}"; + + return $this->send($phone, $message); + } + + /** + * بررسی کد تأیید + */ + public function verifyCode(string $phone, string $code): bool + { + $cacheKey = "sms_verify_{$phone}"; + $storedCode = Cache::get($cacheKey); + + if ($storedCode && $storedCode === $code) { + Cache::forget($cacheKey); + return true; + } + + return false; + } + + /** + * ارسال پیامک + */ + public function send(string $receptor, string $message): array + { + if (empty($this->apiKey)) { + return [ + 'success' => false, + 'message' => 'API Key Kavenegar تنظیم نشده است', + ]; + } + + try { + $response = Http::timeout(10) + ->post("https://api.kavenegar.com/v1/{$this->apiKey}/sms/send.json", [ + 'receptor' => $receptor, + 'sender' => $this->sender, + 'message' => $message, + ]); + + $data = $response->json(); + + if ($response->successful() && isset($data['return']['status']) && $data['return']['status'] == 200) { + return [ + 'success' => true, + 'message' => 'پیامک با موفقیت ارسال شد', + 'cost' => $data['entries'][0]['cost'] ?? 0, + ]; + } + + return [ + 'success' => false, + 'message' => $data['return']['message'] ?? 'خطا در ارسال پیامک', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'خطا در اتصال به سرور Kavenegar: ' . $e->getMessage(), + ]; + } + } + + /** + * ارسال پیامک گروهی + */ + public function sendBulk(array $receptors, string $message): array + { + if (empty($this->apiKey)) { + return [ + 'success' => false, + 'message' => 'API Key Kavenegar تنظیم نشده است', + ]; + } + + try { + $response = Http::timeout(30) + ->post("https://api.kavenegar.com/v1/{$this->apiKey}/sms/send.json", [ + 'receptor' => $receptors, + 'sender' => $this->sender, + 'message' => $message, + ]); + + $data = $response->json(); + + if ($response->successful() && isset($data['return']['status']) && $data['return']['status'] == 200) { + return [ + 'success' => true, + 'message' => 'پیامک با موفقیت ارسال شد', + 'cost' => collect($data['entries'])->sum('cost'), + ]; + } + + return [ + 'success' => false, + 'message' => $data['return']['message'] ?? 'خطا در ارسال پیامک', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'خطا در اتصال به سرور Kavenegar: ' . $e->getMessage(), + ]; + } + } + + /** + * بررسی وضعیت پیامک ارسال شده + */ + public function checkStatus(string $messageId): array + { + try { + $response = Http::timeout(10) + ->get("https://api.kavenegar.com/v1/{$this->apiKey}/select.json", [ + 'messageid' => $messageId, + ]); + + $data = $response->json(); + + if ($response->successful() && isset($data['return']['status']) && $data['return']['status'] == 200) { + $entry = $data['entries'][0] ?? null; + return [ + 'success' => true, + 'status' => $entry['status'] ?? 'unknown', + 'status_text' => $entry['statustext'] ?? 'نامشخص', + ]; + } + + return [ + 'success' => false, + 'message' => $data['return']['message'] ?? 'خطا در بررسی وضعیت', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'خطا در اتصال به سرور Kavenegar: ' . $e->getMessage(), + ]; + } + } +} diff --git a/04_Laravel/app/Services/PdfService.php b/04_Laravel/app/Services/PdfService.php index 8320d53..314f214 100644 --- a/04_Laravel/app/Services/PdfService.php +++ b/04_Laravel/app/Services/PdfService.php @@ -87,6 +87,194 @@ class PdfService return $this->generatePdf($html, 'A5', 'landscape'); } + /** + * تولید PDF فاکتور فروش خدمات (واردات) — مطابق Sheet ENG Invoice + */ + public function importInvoice(Shipment $shipment, array $options = []): string + { + $shipment->loadMissing(['fromCountry', 'toCountry', 'items']); + + // آدرس گیرنده + $receiver = [ + 'name' => $shipment->receiver_name, + 'company' => $shipment->receiver_company, + 'phone' => $shipment->receiver_phone, + 'email' => $shipment->receiver_email, + 'address' => $shipment->receiver_address, + 'city' => $shipment->receiver_city, + 'country' => $shipment->toCountry?->name ?? '', + ]; + + // شماره فاکتور (از شماره AWB مشتق می‌شود) + $invoice_no = $options['invoice_no'] ?? 'PEX' . date('ymd') . substr($shipment->awb_no, -4); + + // تاریخ فاکتور + $invoice_date = $options['invoice_date'] ?? now()->format('l, F j, Y'); + + // دفتر گمرکی + $customs_office = $options['customs_office'] ?? 'Imam Khomeini Airport'; + + // نرخ ارز + $exchange_rate = $options['exchange_rate'] ?? 500000; // پیش‌فرض + + // اقلام خدماتی (۱۰ ردیف ثابت مطابق Sheet ENG Invoice) + $service_items = $this->buildServiceItems($shipment, $options); + + // جمع‌بندی + $freight_items = array_slice($service_items, 0, 5); // آیتم‌های حمل + $clearance_items = array_slice($service_items, 5); // آیتم‌های ترخیص + + $freight_subtotal_currency = array_sum(array_column($freight_items, 'total_currency')); + $freight_subtotal_rial = array_sum(array_column($freight_items, 'total_rial')); + $clearance_subtotal_currency = array_sum(array_column($clearance_items, 'total_currency')); + $clearance_subtotal_rial = array_sum(array_column($clearance_items, 'total_rial')); + $total_currency = $freight_subtotal_currency + $clearance_subtotal_currency; + $total_rial = $freight_subtotal_rial + $clearance_subtotal_rial; + + // شرایط و ضوابط + $terms_and_conditions = $options['terms_and_conditions'] ?? + 'This proforma invoice is issued based on the shipment weight declared by the customer. Final weight and dimensions will be determined after re-weighing at the destination customs.'; + + $data = [ + 'shipment' => $shipment, + 'receiver' => $receiver, + 'invoice_no' => $invoice_no, + 'invoice_date' => $invoice_date, + 'customs_office' => $customs_office, + 'exchange_rate' => $exchange_rate, + 'service_items' => $service_items, + 'freight_subtotal_currency' => $freight_subtotal_currency, + 'freight_subtotal_rial' => $freight_subtotal_rial, + 'clearance_subtotal_currency' => $clearance_subtotal_currency, + 'clearance_subtotal_rial' => $clearance_subtotal_rial, + 'total_currency' => $total_currency, + 'total_rial' => $total_rial, + 'terms_and_conditions' => $terms_and_conditions, + ]; + + $html = view('pdfs.import_invoice', $data)->render(); + + return $this->generatePdf($html, 'A4', 'portrait'); + } + + /** + * ساخت اقلام خدماتی فاکتور واردات + */ + private function buildServiceItems(Shipment $shipment, array $options): array + { + // فیلدهای پیش‌فرض از گزینه‌ها یا مقادیر پیش‌فرض + $fields = [ + 'international_freight' => $options['international_freight'] ?? ($shipment->shipping_price ?? 0), + 'pick_up_fee' => $options['pick_up_fee'] ?? ($shipment->domestic_pickup ?? 0), + 'brand_fee' => $options['brand_fee'] ?? 0, + 'report_fee' => $options['report_fee'] ?? 0, + 'other_charges' => $options['other_charges'] ?? ($shipment->extra_service ?? 0), + 'customs_clearance' => $options['customs_clearance'] ?? 0, + 'domestic_transport' => $options['domestic_transport'] ?? ($shipment->domestic_delivery ?? 0), + 'warehousing_fee' => $options['warehousing_fee'] ?? ($shipment->warehousing_cost ?? 0), + 'order_registration_fee' => $options['order_registration_fee'] ?? 0, + 'other_clearance_charges' => $options['other_clearance_charges'] ?? 0, + ]; + + $exchange_rate = $options['exchange_rate'] ?? 500000; + $weight = $shipment->chargeable_weight ?? $shipment->weight; + + return [ + // آیتم‌های حمل (Freight) + [ + 'description' => 'International Freight', + 'unit' => 'kg', + 'quantity' => $weight, + 'unit_price' => $fields['international_freight'] > 0 ? $fields['international_freight'] / $weight : 0, + 'total_currency' => $fields['international_freight'], + 'total_rial' => $fields['international_freight'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Pick Up Fee', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['pick_up_fee'], + 'total_rial' => $fields['pick_up_fee'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Brand Fee', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['brand_fee'], + 'total_rial' => $fields['brand_fee'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Report Fee', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['report_fee'], + 'total_rial' => $fields['report_fee'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Other Charges', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['other_charges'], + 'total_rial' => $fields['other_charges'] * $exchange_rate, + 'notes' => '', + ], + // آیتم‌های ترخیص (Clearance) + [ + 'description' => 'Customs Clearance', + 'unit' => 'service', + 'quantity' => $weight, + 'unit_price' => $fields['customs_clearance'] > 0 ? $fields['customs_clearance'] / $weight : 0, + 'total_currency' => $fields['customs_clearance'], + 'total_rial' => $fields['customs_clearance'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Domestic Transport', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['domestic_transport'], + 'total_rial' => $fields['domestic_transport'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Warehousing Fee', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['warehousing_fee'], + 'total_rial' => $fields['warehousing_fee'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Order Registration Fee', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['order_registration_fee'], + 'total_rial' => $fields['order_registration_fee'] * $exchange_rate, + 'notes' => '', + ], + [ + 'description' => 'Other Clearance Charges', + 'unit' => '', + 'quantity' => 0, + 'unit_price' => 0, + 'total_currency' => $fields['other_clearance_charges'], + 'total_rial' => $fields['other_clearance_charges'] * $exchange_rate, + 'notes' => '', + ], + ]; + } + /** * تولید بارکد از AWB number — base64 embed (مطمئن‌ترین روش) */ diff --git a/04_Laravel/app/Traits/Auditable.php b/04_Laravel/app/Traits/Auditable.php new file mode 100644 index 0000000..6a593a2 --- /dev/null +++ b/04_Laravel/app/Traits/Auditable.php @@ -0,0 +1,37 @@ +getAttributes(), "ایجاد رکورد جدید"); + }); + + static::updated(function (Model $model) { + $changes = $model->getChanges(); + $original = $model->getOriginal(); + + // حذف فیلدهای زمانی از تغییرات + unset($changes['updated_at'], $original['updated_at']); + + if (!empty($changes)) { + AuditLog::log('updated', $model, $original, $changes, "به‌روزرسانی رکورد"); + } + }); + + static::deleted(function (Model $model) { + AuditLog::log('deleted', $model, $model->getAttributes(), [], "حذف رکورد"); + }); + } + + public function getAuditableChanges(): array + { + return $this->getChanges(); + } +} diff --git a/04_Laravel/config/services.php b/04_Laravel/config/services.php index 6a90eb8..eaa9dd6 100644 --- a/04_Laravel/config/services.php +++ b/04_Laravel/config/services.php @@ -35,4 +35,9 @@ return [ ], ], + 'kavenegar' => [ + 'api_key' => env('KAVENEGAR_API_KEY', ''), + 'sender' => env('KAVENEGAR_SENDER', '10008566'), + ], + ]; diff --git a/04_Laravel/database/migrations/2026_09_03_000001_add_approval_statuses_to_shipments_table.php b/04_Laravel/database/migrations/2026_09_03_000001_add_approval_statuses_to_shipments_table.php new file mode 100644 index 0000000..3bf7f2b --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_03_000001_add_approval_statuses_to_shipments_table.php @@ -0,0 +1,60 @@ +where('status', 'pending_payment') + ->update(['status' => 'approved']); + } + + public function down(): void + { + // تبدیل approved به pending_payment قبل از حذف + DB::table('shipments') + ->whereIn('status', ['pending_approval', 'approved']) + ->update(['status' => 'pending_payment']); + + DB::statement(" + ALTER TABLE `shipments` + MODIFY COLUMN `status` ENUM( + 'pending_payment', + 'cancelled', + 'processed', + 'picked_up', + 'in_transit', + 'out_for_delivery', + 'delivered', + 'failed', + 'returned', + 'archived' + ) DEFAULT 'pending_payment' + "); + } +}; diff --git a/04_Laravel/database/migrations/2026_09_03_000002_create_commitment_forms_table.php b/04_Laravel/database/migrations/2026_09_03_000002_create_commitment_forms_table.php new file mode 100644 index 0000000..9ff38ce --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_03_000002_create_commitment_forms_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('file_path'); + $table->string('file_type'); // pdf, png, jpg + $table->unsignedBigInteger('file_size'); + $table->enum('direction', ['export', 'import', 'both'])->default('both'); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->unsignedBigInteger('uploaded_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('uploaded_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('commitment_forms'); + } +}; diff --git a/04_Laravel/database/migrations/2026_09_03_000003_add_credit_fields_to_users_table.php b/04_Laravel/database/migrations/2026_09_03_000003_add_credit_fields_to_users_table.php new file mode 100644 index 0000000..4fa2665 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_03_000003_add_credit_fields_to_users_table.php @@ -0,0 +1,23 @@ +decimal('credit_limit', 15, 2, true)->default(0)->after('phone'); + $table->decimal('credit_used', 15, 2, true)->default(0)->after('credit_limit'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['credit_limit', 'credit_used']); + }); + } +}; diff --git a/04_Laravel/database/migrations/2026_09_03_000004_create_shipment_checklists_table.php b/04_Laravel/database/migrations/2026_09_03_000004_create_shipment_checklists_table.php new file mode 100644 index 0000000..5b27fd5 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_03_000004_create_shipment_checklists_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('shipment_id')->constrained()->onDelete('cascade'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); // کارمند + $table->string('status'); // وضعیت فعلی سفارش + $table->boolean('is_completed')->default(false); // آیا چک‌لیست تکمیل شده + $table->text('notes')->nullable(); // یادداشت‌های کارمند + $table->timestamp('completed_at')->nullable(); // زمان تکمیل + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipment_checklists'); + } +}; diff --git a/04_Laravel/database/migrations/2026_09_03_000005_create_audit_logs_table.php b/04_Laravel/database/migrations/2026_09_03_000005_create_audit_logs_table.php new file mode 100644 index 0000000..cf18c46 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_03_000005_create_audit_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); // کاربری که تغییر داده + $table->string('event'); // نوع رویداد (create, update, delete, etc.) + $table->string('auditable_type'); // مدل مورد نظر + $table->unsignedBigInteger('auditable_id'); // شناسه رکورد + $table->json('old_values'); // مقادیر قبلی + $table->json('new_values'); // مقادیر جدید + $table->text('description')->nullable(); // توضیحات + $table->string('ip_address', 45)->nullable(); // آدرس IP + $table->string('user_agent')->nullable(); // مرورگر کاربر + $table->timestamps(); + + $table->index(['auditable_type', 'auditable_id']); + $table->index('event'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/04_Laravel/database/migrations/2026_09_03_000006_add_import_invoice_fields_to_shipments_table.php b/04_Laravel/database/migrations/2026_09_03_000006_add_import_invoice_fields_to_shipments_table.php new file mode 100644 index 0000000..6c4b714 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_03_000006_add_import_invoice_fields_to_shipments_table.php @@ -0,0 +1,38 @@ +decimal('brand_fee', 15, 2, true)->default(0)->after('cod_amount'); + $table->decimal('report_fee', 15, 2, true)->default(0)->after('brand_fee'); + $table->decimal('customs_clearance_cost', 15, 2, true)->default(0)->after('report_fee'); + $table->decimal('order_registration_fee', 15, 2, true)->default(0)->after('customs_clearance_cost'); + $table->decimal('other_clearance_charges', 15, 2, true)->default(0)->after('order_registration_fee'); + $table->decimal('exchange_rate', 10, 4, true)->default(0)->after('other_clearance_charges'); + $table->string('goods_nature')->nullable()->after('exchange_rate'); + $table->string('invoice_currency', 10)->default('USD')->after('goods_nature'); + }); + } + + public function down(): void + { + Schema::table('shipments', function (Blueprint $table) { + $table->dropColumn([ + 'brand_fee', + 'report_fee', + 'customs_clearance_cost', + 'order_registration_fee', + 'other_clearance_charges', + 'exchange_rate', + 'goods_nature', + 'invoice_currency', + ]); + }); + } +}; diff --git a/04_Laravel/read_excel.php b/04_Laravel/read_excel.php new file mode 100644 index 0000000..ac271d2 --- /dev/null +++ b/04_Laravel/read_excel.php @@ -0,0 +1,26 @@ +load(__DIR__ . '/../01_Documents/Sheet ENG invoice.xlsx'); +$sheet = $spreadsheet->getActiveSheet(); + +echo 'Sheet: ' . $sheet->getTitle() . PHP_EOL; +echo 'Rows: ' . $sheet->getHighestRow() . PHP_EOL; + +// Use toArray for cleaner output +$data = $sheet->toArray(null, true, true, true); + +echo PHP_EOL . '=== CONTENT ===' . PHP_EOL; + +foreach ($data as $rowIndex => $row) { + $line = ''; + foreach ($row as $colIndex => $value) { + if ($value !== null && trim((string)$value) !== '') { + $line .= $colIndex . '=' . trim((string)$value) . ' | '; + } + } + if ($line) echo 'Row' . $rowIndex . ': ' . $line . PHP_EOL; +} diff --git a/04_Laravel/resources/views/filament/pages/bulk-tracking-import.blade.php b/04_Laravel/resources/views/filament/pages/bulk-tracking-import.blade.php new file mode 100644 index 0000000..16eb46f --- /dev/null +++ b/04_Laravel/resources/views/filament/pages/bulk-tracking-import.blade.php @@ -0,0 +1,44 @@ + +
+ {{ $this->form }} + +
+ + ایمپورت وضعیت ترکینگ + +
+
+ +
+

راهنما

+
+

فرمت فایل CSV:

+
    +
  • خط اول: هدر (.awb_no,status,description)
  • +
  • هر سطر بعدی: یک سفارش
  • +
  • awb_no: شماره سفارش (الزامی)
  • +
  • status: وضعیت جدید (اختیاری - اگر خالی باشد از وضعیت پیش‌فرض استفاده می‌شود)
  • +
  • description: توضیحات (اختیاری)
  • +
+ +

وضعیت‌های مجاز:

+
+ pending_approval + approved + pending_payment + paid + processed + picked_up + in_transit + out_for_delivery + delivered + cancelled +
+ +

نمونه فایل:

+
.awb_no,status,description
+123456789,in_transit,ارسال شد از تهران
+987654321,delivered,تحویل داده شد
+
+
+
diff --git a/04_Laravel/resources/views/pdfs/import_invoice.blade.php b/04_Laravel/resources/views/pdfs/import_invoice.blade.php new file mode 100644 index 0000000..a847850 --- /dev/null +++ b/04_Laravel/resources/views/pdfs/import_invoice.blade.php @@ -0,0 +1,353 @@ + + + + + + + +
+ +
+

INVOICE TO

+

{{ $receiver['name'] }}

+

{{ $receiver['company'] ?: '' }}

+

{{ $receiver['address'] }}

+

{{ $receiver['city'] }}, {{ $receiver['country'] }}

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
Invoice No.{{ $invoice_no }}
Date{{ $invoice_date }}
Origin{{ $shipment->fromCountry?->name }}
TransportAir Freight
Customs{{ $customs_office ?: 'Imam Khomeini Airport' }}
+
+ + +
+

SHIPMENT DETAILS

+
+
+
Actual Weight:
+
{{ number_format($shipment->weight, 2) }} KG
+
+
+
Volume:
+
{{ $shipment->dimensions ?: '—' }}
+
+
+
Volumetric Weight:
+
{{ number_format($shipment->volumetric_weight ?? 0, 2) }} KG
+
+
+
Chargeable Weight:
+
{{ number_format($shipment->chargeable_weight ?? $shipment->weight, 2) }} KG
+
+
+
Goods Nature:
+
{{ $shipment->content_description ?: 'General Goods' }}
+
+
+
Exchange Rate:
+
{{ number_format($exchange_rate) }} IRR
+
+
+
+ + +
+ + + + + + + + + + + + + + + @foreach($service_items as $index => $item) + + + + + + + + + + + @endforeach + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.Item DescriptionUnitQtyUnit PriceTotal (Currency)Total (Rial)Notes
{{ $index + 1 }}{{ $item['description'] }}{{ $item['unit'] ?: 'service' }}{{ number_format($item['quantity'], 2) }}{{ $item['unit_price'] > 0 ? number_format($item['unit_price'], 2) : '0.00' }}{{ $item['total_currency'] > 0 ? number_format($item['total_currency'], 2) : '0.00' }}{{ $item['total_rial'] > 0 ? number_format($item['total_rial']) : '0' }}{{ $item['notes'] ?: '' }}
Freight Subtotal{{ number_format($freight_subtotal_currency, 2) }}{{ number_format($freight_subtotal_rial) }}
Clearance Subtotal{{ number_format($clearance_subtotal_currency, 2) }}{{ number_format($clearance_subtotal_rial) }}
Currency Rate:{{ number_format($exchange_rate) }}
TOTAL{{ number_format($total_currency, 2) }}{{ number_format($total_rial) }}
+
+ + +
+

TERMS & CONDITIONS

+

{{ $terms_and_conditions }}

+
+ + +
+
+
Authorised Signature
+
IFNEX Logistics
+
+
+ + + +
+ + diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php index 8303a9d..9386452 100644 --- a/04_Laravel/routes/api.php +++ b/04_Laravel/routes/api.php @@ -1,10 +1,14 @@ prefix('v1')->group(function () { Route::post('/orders/{shipment}/pay-wallet', [CustomerOrderController::class, 'payFromWallet']); Route::post('/orders/{shipment}/pay-gateway', [CustomerOrderController::class, 'payViaGateway']); }); + + // ─── Staff Orders API (تأیید سفارشات) ─── + Route::prefix('staff')->group(function () { + // لیست سفارشات در انتظار تأیید + Route::get('/orders/pending-approval', [StaffOrderController::class, 'pendingApproval']); + + // تأیید یا رد سفارش + Route::post('/orders/{shipment}/approve', [StaffOrderController::class, 'approve']); + Route::post('/orders/{shipment}/reject', [StaffOrderController::class, 'reject']); + + // ─── Customer Financial API (وضعیت مالی مشتری) ─── + Route::get('/customers/search', [CustomerFinancialController::class, 'search']); + Route::get('/customers/{customer}/financial-status', [CustomerFinancialController::class, 'financialStatus']); + }); }); // ══════════════════════════════════════════════════════════════ @@ -95,4 +113,15 @@ Route::any('/v1/payment/callback', [PaymentController::class, 'callback']) // ══════════════════════════════════════════════════════════════ Route::post('/v1/calculate', [PricingController::class, 'calculate']); Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']); -Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']); \ No newline at end of file +Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']); + +// API فایل‌های تعهدنامه (عمومی) +Route::get('/v1/commitment-forms', [CommitmentFormController::class, 'index']); +Route::get('/v1/commitment-forms/{direction}', [CommitmentFormController::class, 'byDirection']); + +// API تأیید موبایل (عمومی) +Route::post('/v1/verify/send-code', [MobileVerificationController::class, 'sendCode']); +Route::post('/v1/verify/check-code', [MobileVerificationController::class, 'checkCode']); + +// API ارسال پیامک (نیاز به auth دارد) +Route::middleware(['auth:sanctum'])->post('/v1/verify/send-sms', [MobileVerificationController::class, 'sendSms']); \ No newline at end of file diff --git a/04_Laravel/routes/web.php b/04_Laravel/routes/web.php index a1629d7..b5e6930 100644 --- a/04_Laravel/routes/web.php +++ b/04_Laravel/routes/web.php @@ -19,6 +19,7 @@ Route::middleware('auth')->group(function () { Route::get('/shipments/{shipment}/pdf/awb', [ShipmentPdfController::class, 'awb'])->name('shipments.pdf.awb'); Route::get('/shipments/{shipment}/pdf/invoice', [ShipmentPdfController::class, 'invoice'])->name('shipments.pdf.invoice'); Route::get('/shipments/{shipment}/pdf/label', [ShipmentPdfController::class, 'label'])->name('shipments.pdf.label'); + Route::get('/shipments/{shipment}/pdf/import-invoice', [ShipmentPdfController::class, 'importInvoice'])->name('shipments.pdf.import-invoice'); }); // صفحات نتیجه پرداخت diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..561a243 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,259 @@ +# IFNEX Logistics Management System — Agent Guide + +> **هدف:** راهنمای جامع برای دستیار هوش مصنوعی (OpenCode + DeepSeek Flash) جهت درک سریع پروژه، معماری، قراردادها و نکات کلیدی. +> +> **نسخه:** 1.0 +> **تاریخ:** 2026-09-02 +> **مدل پیشنهادی:** DeepSeek Flash (تنظیم شده با API) + +--- + +## 🎯 خلاصه پروژه + +سیستم مدیریت لجستیک بین‌المللی با معماری Headless (Laravel 11 به‌عنوان بک‌اند، WordPress به‌عنوان فرانت‌اند، Filament 3.3 به‌عنوان پنل مدیریت). جایگزین فرآیندهای دستی مبتنی بر اکسل شده و از Multi-Package، فاکتور گمرکی (Invoice)، کیف پول دیجیتال، درگاه پرداخت Zarinpal، تولید PDF با بارکد، و سیستم رهگیری پیشرفته پشتیبانی می‌کند. + +**وضعیت فعلی:** فازهای ۰، ۱، ۲، ۳ و ۳.۵ (۹۰٪) تکمیل شده‌اند. تنها مورد باقیمانده از فاز ۳.۵، پشتیبانی کامل چندزبانه (i18n) است که به زمان دیپلوی موکول شده است. + +--- + +## 🧰 تکنولوژی‌ها و نسخه‌ها + +| لایه | تکنولوژی | نسخه / توضیح | +|------|-----------|--------------| +| **Backend** | Laravel | 11.x | +| **PHP** | PHP | ^8.2 (۸.۳ نیز پشتیبانی می‌شود) | +| **Admin Panel** | Filament | 3.3.x | +| **Frontend** | WordPress | 7.0.3 + قالب سفارشی IFNEX | +| **Authentication** | Laravel Sanctum + Bridge Auth | بدون رمز عبور (API Key مشترک) | +| **Payment** | Zarinpal + Mock Gateway (تست) | کلید sandbox برای تست لوکال | +| **PDF & Barcode** | Dompdf + picqer/php-barcode-generator | تولید AWB، Invoice، Label | +| **Excel** | maatwebsite/excel | Import/Export نرخ‌ها و داده‌های تاریخی | +| **Date/Time** | morilog/jalali + Carbon | تاریخ شمسی در نمایش، میلادی در DB | +| **Database** | MySQL | 8.0+ | +| **Queue** | Redis (ترجیح) / Database | برای پردازش‌های سنگین | +| **Server** | HestiaCP + Nginx + PHP-FPM | تولید (api.ifnex.vernahost.ir) | + +--- + +## 📂 ساختار دایرکتوری (کلیدی) + +IFNEX-Logistics/ +├── 01_Documents/ # مستندات فنی (تحلیل اکسل، نقشه راه، چک‌لیست، ...) +│ ├── EXCEL_ANALYSIS.md # تحلیل کامل فایل‌های اکسل (۳۹۵۰ رکورد) +│ ├── IFNEX_Phase0_Checklist.md # چک‌لیست کامل فازها +│ ├── IFNEX_Roadmap.md # نقشه راه ۵ فازی +│ ├── IFNEX_File_Map.md # نقشه ۱۰۰+ فایل پروژه +│ ├── IFNEX_I18N_Strategy.md # استراتژی چندزبانه +│ ├── IFNEX_Commercial_Model.md # مدل تجاری و پلن‌های فروش +│ └── DESIGN_SYSTEM.md # رنگ‌ها، تایپوگرافی، فاصله‌گذاری +│ +├── 03_WordPress/ # فرانت‌اند وردپرس +│ ├── wp-content/themes/ifnex/ # قالب سفارشی +│ └── wp-content/plugins/ifnex-bridge/ # پلاگین ارتباط با لاراول +│ +├── 04_Laravel/ # بک‌اند لاراول (هسته اصلی) +│ ├── app/ +│ │ ├── Enums/ # ShipmentStatus, ShipmentDirection, ShipmentType, PaymentGateway, TransactionStatus +│ │ ├── Filament/ +│ │ │ ├── Resources/ # ۱۰+ Resource (Shipment, Country, ShippingRate, Currency, ...) +│ │ │ ├── Pages/ # Settings, PriceTest, ImportRates, FinancialReport +│ │ │ └── Widgets/ # داشبورد +│ │ ├── Http/ +│ │ │ ├── Controllers/Api/ # Track, Pricing, Auth, Bridge, Customer, Wallet, Payment, MockGateway +│ │ │ └── Middleware/ # ApiKeyMiddleware +│ │ ├── Models/ # ۱۷ مدل (Shipment, Package, Invoice, Wallet, ...) +│ │ ├── Notifications/ # ShipmentUpdatedNotification +│ │ ├── Services/ # PriceCalculator, Pdf, Tracking, OrderPayment, Zarinpal, MockZarinpal +│ │ ├── Imports/ # OldShipmentsImport, ShippingRatesImport +│ │ └── Exports/ # ShippingRatesTemplateExport +│ ├── database/ +│ │ ├── migrations/ # ۲۸+ migration +│ │ └── seeders/ # Countries, SystemSettings, DatabaseSeeder +│ ├── resources/views/ +│ │ ├── pdfs/ # awb, invoice, label (با بارکد) +│ │ └── filament/ # صفحات سفارشی +│ └── routes/ +│ ├── api.php # ۳۰+ endpoint +│ └── web.php # روت‌های عمومی (دانلود template، Mock Gateway) +│ +├── DEPLOYMENT.md # راهنمای استقرار در سرور +└── README.md # معرفی کلی پروژه + + +--- + +## 🔑 مفاهیم کلیدی بیزینس + +### ۱. مرسوله (Shipment) +- **انواع (Type):** `DOC_NORMAL`، `DOC_ECONOMY`، `PARCEL` +- **جهت (Direction):** `export` (صادرات) و `import` (واردات) +- **وضعیت (Status):** `pending` → `confirmed` → `picked_up` → `in_transit` → `out_for_delivery` → `delivered` / `failed` / `returned` / `cancelled` +- **ویژگی‌ها:** دارای چند بسته (`packages`)، اقلام گمرکی (`items` - فقط برای PARCEL)، تاریخچه تغییرات وضعیت (`statusHistories`)، شرکت‌های حمل (`carrierMappings`)، رویدادهای رهگیری (`trackingEvents`). + +### ۲. بسته (Package) +- هر مرسوله می‌تواند چندین بسته داشته باشد. +- وزن حجمی = `(Length × Width × Height) / 5000` (استاندارد IATA). +- وزن قابل پرداخت = `max(وزن واقعی, وزن حجمی)`. + +### ۳. فاکتور گمرکی (Invoice) +- فقط برای مرسوله‌های نوع `PARCEL` صادر می‌شود. +- حداکثر ۹ قلم کالا (شرح، HS Code، تعداد، قیمت واحد، مجموع به USD). +- مجموع کل فاکتور در `shipments.invoice_total_usd` ذخیره می‌شود. + +### ۴. کیف پول (Wallet) +- هر کاربر یک کیف پول دارد. +- تراکنش‌ها (`WalletTransaction`) با نوع `deposit`، `payment`، `refund`، `adjustment`. +- پرداخت از کیف پول یا درگاه Zarinpal (با Mock برای تست). + +### ۵. احراز هویت Bridge +- کاربران وردپرس بدون نیاز به رمز عبور، از طریق `POST /api/v1/bridge/login` با ارسال `bridge_api_key` و `wp_user_id` وارد لاراول می‌شوند. +- پاسخ شامل `token` Sanctum (معتبر ۳۰ روز) و اطلاعات کاربر است. +- کلید `IFNEX_BRIDGE_API_KEY` باید در هر دو طرف (`.env` لاراول و تنظیمات پلاگین وردپرس) یکسان باشد. + +### ۶. رهگیری (Tracking) +- کاربر با شماره AWB جستجو می‌کند. +- سیستم رویدادهای رهگیری را از جدول `shipment_tracking_events` با `source` (manual, api, system) نمایش می‌دهد. +- در فاز ۳.۵، صفحه رهگیری با تایم‌لاین زیبا و badge رنگی بازطراحی شده است. + +### ۷. قیمت‌گذاری (Pricing) +- بر اساس ۴ زون مجزا (Export/Import × Parcel/Doc) و ۳ نوع سرویس. +- فرمول: قیمت پایه (AED) × ضریب سود × نرخ تبدیل به ریال + هزینه‌های جانبی (Packing, Domestic Pickup, ...) + VAT (۹٪). +- محموله‌های بالای ۳۰ کیلوگرم مشمول نرخ ویژه (Spot Rate) هستند و محاسبه آنلاین ندارند. + +--- + +## ⚙️ دستورات روزمره (برای ایجنت) + +```bash +# نصب وابستگی‌ها +composer install + +# تنظیم محیط +cp .env.example .env +php artisan key:generate + +# دیتابیس (ابتدا باید ایجاد شود) +php artisan migrate --force +php artisan db:seed --force + +# اجرای سرور توسعه +php artisan serve + +# اجرای Queue (برای پردازش‌های سنگین) +php artisan queue:work + +# تست‌ها +php artisan test + +# پاک‌سازی کش +php artisan optimize:clear +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan filament:clear-cached-components + +# ایمپورت/اکسپورت نرخ‌ها +php artisan ifnex:import-rates +php artisan ifnex:export-rates-template + +# بروزرسانی نرخ ارز (کرون) +php artisan ifnex:update-exchange-rates + +# تولید API Token برای ادمین +php artisan ifnex:generate-api-token + + +🧪 متغیرهای محیطی کلیدی (.env) +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8000 + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=ifnex_db +DB_USERNAME=root +DB_PASSWORD= + +# IFNEX اختصاصی +IFNEX_API_KEY=ifnex-local-dev-key +IFNEX_BRIDGE_API_KEY=ifnex-bridge-secret-key-2026-vernasoft # باید با وردپرس یکی باشد +IFNEX_TRACKING_RATE_LIMIT=60 + +# CORS (فقط دامنه‌های مجاز وردپرس) +CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1 + +# Zarinpal (برای تست از Mock استفاده کن) +ZARINPAL_MERCHANT_ID=fake-merchant-id-for-testing # اگر fake باشد، MockGateway فعال می‌شود +ZARINPAL_SANDBOX=true +ZARINPAL_CALLBACK_URL=http://localhost:8000/api/v1/payment/callback +ZARINPAL_FRONTEND_SUCCESS_URL=http://localhost/IFNEX-Logistics/03_WordPress/wallet +ZARINPAL_FRONTEND_FAILURE_URL=http://localhost/IFNEX-Logistics/03_WordPress/wallet + +# Wallet +WALLET_MIN_DEPOSIT=10000 +WALLET_MAX_DEPOSIT=500000000 +WALLET_AUTO_CREATE=true + + +⚠️ خط قرمزها (ممنوعیت‌های مطلق) + +❌ هرگز ✅ همیشه +برگرداندن کشورها به ۲ زون ۴ زون مجزا (Export/Import × Parcel/Doc) +استفاده از ۲ نوع سرویس ۳ نوع (DOC_NORMAL, DOC_ECONOMY, PARCEL) +ذخیره تاریخ شمسی در DB ذخیره timestamp میلادی + تبدیل در نمایش +CORS * در Production CORS محدود به دامنه وردپرس +کامیت .env در Git در .gitignore باشد +APP_DEBUG=true در Production APP_DEBUG=false +PDF فارسی (AWB/Invoice/Label) همیشه انگلیسی (برای حمل بین‌المللی) +کپی از DHL طراحی منحصر به فرد IFNEX +استفاده از wire:click برای دانلود استفاده از
با روت مستقیم +getFormActions() در Custom Pages استفاده از wire:click در Blade +هدایت AJAX به redirect() استفاده از payment_url در response JSON +متدهای نوتیفیکیشن بیرون از کلاس داخل کلاس IFNEX_User_Bridge +📌 نکات ویژه برای ایجنت (DeepSeek Flash) +هنگام تولید کد جدید، حتماً از Enum‌ها به جای رشته‌های سخت‌کد شده استفاده کن. + +برای هر مدل جدید، migration، مدل، و در صورت نیاز کنترلر/ریسورس Filament بساز. + +خطاهای رایج: + +عدم وجود فیلد deleted_at در کوئری‌ها (از SoftDeletes استفاده کن). + +فراموشی fillable یا casts در مدل‌ها. + +فراموشی $with برای بارگذاری روابط در Resourceها. + +استفاده از redirect() در کنترلرهای API (باید JSON برگردانند). + +برای تغییر وضعیت مرسوله، حتماً تایم‌لاین را به‌روز کن (مدل ShipmentStatusHistory). + +در فرم سفارش مشتری، مرحله Invoice فقط برای نوع PARCEL نمایش داده شود. + +تولید PDF با بارکد به‌صورت base64 embed انجام شود. + +Bridge Auth نیازی به رمز عبور ندارد؛ فقط از IFNEX_BRIDGE_API_KEY استفاده می‌کند. + +تست لوکال پرداخت: از ZARINPAL_MERCHANT_ID=fake-merchant-id-for-testing برای فعال‌سازی Mock Gateway استفاده کن. + +مدیریت تاریخ: همیشه از Carbon استفاده کن و تاریخ را به‌صورت Y-m-d H:i:s در DB ذخیره کن. + +برای کوئری‌های سنگین، از chunk() یا cursor() استفاده کن تا حافظه مصرف نشود. + + +📚 مستندات مرجع (برای مطالعه بیشتر) +فایل محتوا +01_Documents/EXCEL_ANALYSIS.md تحلیل کامل فایل‌های اکسل (۳۹۵۰ رکورد، فرمول‌ها، زون‌ها) +01_Documents/IFNEX_Phase0_Checklist.md چک‌لیست کامل فازها (۰ تا ۳.۵) +01_Documents/IFNEX_Roadmap.md نقشه راه ۵ فازی +01_Documents/IFNEX_File_Map.md نقشه ۱۰۰+ فایل پروژه +01_Documents/IFNEX_I18N_Strategy.md استراتژی چندزبانه (برای زمان دیپلوی) +01_Documents/IFNEX_Commercial_Model.md مدل تجاری و پلن‌های فروش +01_Documents/DESIGN_SYSTEM.md رنگ‌ها، تایپوگرافی، فاصله‌گذاری +DEPLOYMENT.md راهنمای کامل استقرار روی سرور +04_Laravel/README.md راهنمای بک‌اند +تاریخ: 2026-09-02 +نسخه: 1.0 +تهیه‌کننده: VernaSoft Group — Kazem Alghasi +مدل هدف: DeepSeek Flash (API) + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index aafa83e..0033cd8 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,6 +1,6 @@ # راهنمای استقرار IFNEX -> **آخرین بروزرسانی:** 2026-08-29 +> **آخرین بروزرسانی:** 2026-09-03 --- @@ -8,8 +8,8 @@ | محیط | دامنه | نقش | |-------|-------|------| -| Production | api.ifnex.vernahost.ir | API لاراول | -| Production | ifnex.vernahost.ir | وب‌سایت وردپرس | +| Production | system.ifnex.ir | API لاراول | +| Production | ifnex.ir | وب‌سایت وردپرس | | Local | localhost:8000 | توسعه | --- @@ -61,7 +61,7 @@ nano .env ```env APP_ENV=production APP_DEBUG=false -APP_URL=https://api.ifnex.vernahost.ir +APP_URL=https://system.ifnex.ir DB_HOST=localhost DB_DATABASE=ifnex_db @@ -71,14 +71,18 @@ DB_PASSWORD=STRONG_PASSWORD # مهم: Bridge API Key باید با وردپرس یکسان باشد IFNEX_BRIDGE_API_KEY=ifnex-bridge-secret-key-2026-vernasoft -CORS_ALLOWED_ORIGINS=https://ifnex.vernahost.ir +CORS_ALLOWED_ORIGINS=https://ifnex.ir # Payment Gateway ZARINPAL_SANDBOX=false ZARINPAL_MERCHANT_ID=YOUR_REAL_MERCHANT_ID -ZARINPAL_CALLBACK_URL=https://api.ifnex.vernahost.ir/api/v1/payment/callback -ZARINPAL_FRONTEND_SUCCESS_URL=https://ifnex.vernahost.ir/wallet -ZARINPAL_FRONTEND_FAILURE_URL=https://ifnex.vernahost.ir/wallet +ZARINPAL_CALLBACK_URL=https://system.ifnex.ir/api/v1/payment/callback +ZARINPAL_FRONTEND_SUCCESS_URL=https://ifnex.ir/wallet +ZARINPAL_FRONTEND_FAILURE_URL=https://ifnex.ir/wallet + +# Kavenegar SMS Service +KAVENEGAR_API_KEY=YOUR_KAVENEGAR_API_KEY +KAVENEGAR_SENDER=10008566 ``` ### ۱.۴ دیتابیس diff --git a/README.md b/README.md index afab767..bdb4b9c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,16 @@ - **اعلان‌های دیتابیس** برای ادمین و مشتری با badge unread در سایدبار - **پشتیبانی از ۱۹۲ کشور** با پیش‌شماره تلفن استاندارد ISO 3166-1 - **اعتبارسنجی فرم‌ها** شامل تشخیص خودکار زبان فارسی/انگلیسی و پیش‌شماره خودکار +- **سیستم تأیید سفارش** با فلوی `pending_approval → approved → paid → processed` +- **فرم تعهدنامه** با قابلیت آپلود فایل توسط مدیر و مشاهده توسط مشتری +- **فاکتور واردات** با طراحی مطابق Sheet ENG Invoice +- **مانده حساب** با نمایش موجودی کیف پول منهای بدهی‌ها +- **وضعیت مالی مشتری** برای مدیر/کارمند با جزئیات بدهی به هر ارز +- **سیستم اعتباردهی (Credit)** با قابلیت افزایش/کاهش توسط مدیر کل +- **چک‌لیست کارمند** برای هر سفارش + تاریخچه مراحل +- **ایمپورت گروهی وضعیت ترکینگ** با فایل CSV +- **سیستم Audit Log** برای لاگ تغییرات مدیر +- **اتصال سرویس اس‌ام‌اس Kavenegar** برای تأیید موبایل و ارسال پیامک ---