feat(core): implement order approval flow, credit system, and import invoicing

Introduce a comprehensive set of commercial features including a multi-step
order approval workflow, customer credit management, and specialized
import service invoicing.

Key changes:
- Implement `pending_approval` and `approved` shipment statuses to allow
  staff verification before customer payment.
- Add a credit system to `User` model with `credit_limit` and `credit_used`
  to manage customer balances and debts.
- Develop a new `importInvoice` PDF generation service following the
  "Sheet ENG Invoice" specification for import services.
- Add Filament resources for managing Audit Logs, Commitment Forms,
  Customer Credits, and Shipment Checklists.
- Implement staff-specific APIs for order approval/rejection and
  customer financial status monitoring.
- Integrate Kavenegar SMS service for mobile verification and notifications.
- Add bulk tracking import functionality via CSV/Excel.
- Update WordPress bridge assets (CSS/JS) to support the new multi-step
  order form UI and updated redirection logic.
- Update deployment configurations and documentation to reflect new
  production domains and feature sets.
This commit is contained in:
Kazem Alghasi 2026-09-03 06:04:20 +03:30
parent b35e933475
commit 02c29db696
51 changed files with 3604 additions and 49 deletions

Binary file not shown.

Binary file not shown.

View File

@ -448,3 +448,164 @@
gap: 0.5rem !important; 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;
}

View File

@ -731,7 +731,9 @@ jQuery(document).ready(function($) {
btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش'); btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش');
if (res.success) { if (res.success) {
var orderId = res.data.data.id || res.data.id; 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 { } else {
$('#ifnex-submit-error').text(res.data || 'خطا در ثبت سفارش').show(); $('#ifnex-submit-error').text(res.data || 'خطا در ثبت سفارش').show();
} }

View File

@ -231,18 +231,41 @@ function ifnex_user_profile_shortcode($atts) {
<?php if ($atts['show_wallet'] === 'yes' && !empty($profile['wallet'])): ?> <?php if ($atts['show_wallet'] === 'yes' && !empty($profile['wallet'])): ?>
<!-- موجودی کیف پول --> <!-- موجودی کیف پول -->
<div class="ifnex-wallet-card"> <div class="ifnex-wallet-card" style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border: 1px solid #bae6fd; border-radius: 12px; padding: 20px; margin-bottom: 20px;">
<h3>💰 کیف پول</h3> <h3 style="margin: 0 0 15px 0; color: #0369a1;">💰 وضعیت مالی</h3>
<div class="ifnex-wallet-balance">
<span class="ifnex-balance-amount"> <!-- مانده حساب -->
<?php echo number_format($profile['wallet']['balance']); ?> ریال <div style="background: white; border-radius: 8px; padding: 15px; margin-bottom: 15px; border: 1px solid #e0f2fe;">
</span> <div style="font-size: 12px; color: #666; margin-bottom: 5px;">مانده حساب (موجودی منهای بدهی‌ها)</div>
<?php if ($profile['wallet']['is_frozen']): ?> <div style="font-size: 24px; font-weight: bold; color: <?php echo ($profile['account_balance'] ?? 0) >= 0 ? '#059669' : '#dc2626'; ?>;">
<span class="ifnex-badge ifnex-badge-danger">مسدود</span> <?php echo number_format($profile['account_balance'] ?? 0); ?> ریال
<?php else: ?> </div>
<span class="ifnex-badge ifnex-badge-success">فعال</span> <?php if (($profile['pending_payments_total'] ?? 0) > 0): ?>
<div style="font-size: 11px; color: #dc2626; margin-top: 5px;">
⚠️ بدهی در انتظار پرداخت: <?php echo number_format($profile['pending_payments_total']); ?> ریال
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- جزئیات -->
<div style="display: flex; gap: 15px;">
<div style="flex: 1; background: white; border-radius: 8px; padding: 12px; border: 1px solid #e0f2fe;">
<div style="font-size: 11px; color: #666;">موجودی کیف پول</div>
<div style="font-size: 16px; font-weight: bold; color: #0369a1;">
<?php echo number_format($profile['wallet']['balance']); ?> ریال
</div>
</div>
<div style="flex: 1; background: white; border-radius: 8px; padding: 12px; border: 1px solid #e0f2fe;">
<div style="font-size: 11px; color: #666;">وضعیت</div>
<div style="font-size: 14px; font-weight: bold;">
<?php if ($profile['wallet']['is_frozen']): ?>
<span style="color: #dc2626;">🔴 مسدود</span>
<?php else: ?>
<span style="color: #059669;">🟢 فعال</span>
<?php endif; ?>
</div>
</div>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
@ -256,7 +279,11 @@ function ifnex_user_profile_shortcode($atts) {
<div class="ifnex-stat-label">کل سفارشات</div> <div class="ifnex-stat-label">کل سفارشات</div>
</div> </div>
<div class="ifnex-stat-card ifnex-stat-warning"> <div class="ifnex-stat-card ifnex-stat-warning">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['pending_orders']); ?></div> <div class="ifnex-stat-value"><?php echo number_format($profile['stats']['pending_approval'] ?? 0); ?></div>
<div class="ifnex-stat-label">در انتظار تأیید</div>
</div>
<div class="ifnex-stat-card ifnex-stat-info">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['pending_payment'] ?? 0); ?></div>
<div class="ifnex-stat-label">در انتظار پرداخت</div> <div class="ifnex-stat-label">در انتظار پرداخت</div>
</div> </div>
<div class="ifnex-stat-card ifnex-stat-info"> <div class="ifnex-stat-card ifnex-stat-info">
@ -405,7 +432,7 @@ function ifnex_orders_list_shortcode($atts) {
</button> </button>
<?php endif; ?> <?php endif; ?>
<?php if (!$order['status']['is_paid']): ?> <?php if ($order['status']['can_pay']): ?>
<a href="<?php echo esc_url(home_url('/order-payment/?order_id=' . $order['id'])); ?>" <a href="<?php echo esc_url(home_url('/order-payment/?order_id=' . $order['id'])); ?>"
class="ifnex-btn ifnex-btn-sm ifnex-btn-success"> class="ifnex-btn ifnex-btn-sm ifnex-btn-success">
💳 پرداخت 💳 پرداخت
@ -695,11 +722,18 @@ function ifnex_order_payment_shortcode($atts) {
$data = $order['data']; $data = $order['data'];
// بررسی وضعیت // بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند)
if ($data['status']['value'] !== 'pending_payment') { if ($data['status']['value'] !== 'approved') {
return '<div class="ifnex-success-box"> $message = 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.';
<h3> این سفارش قبلاً پرداخت شده است</h3> if ($data['status']['value'] === 'pending_approval') {
<p>وضعیت: ' . esc_html($data['status']['label']) . '</p> $message = 'این سفارش در انتظار تأیید کارمند است. پس از تأیید، گزینه پرداخت فعال خواهد شد.';
} elseif ($data['status']['is_paid']) {
$message = 'این سفارش قبلاً پرداخت شده است.';
}
return '<div class="ifnex-warning-box">
<h3> ' . $message . '</h3>
<p>وضعیت فعلی: ' . esc_html($data['status']['label']) . '</p>
<a href="' . esc_url(home_url('/my-orders/')) . '" class="ifnex-btn ifnex-btn-primary">بازگشت به سفارشات</a> <a href="' . esc_url(home_url('/my-orders/')) . '" class="ifnex-btn ifnex-btn-primary">بازگشت به سفارشات</a>
</div>'; </div>';
} }
@ -938,7 +972,7 @@ function ifnex_order_detail_shortcode($atts) {
بازگشت به سفارشات بازگشت به سفارشات
</a> </a>
<?php if (!$status['is_paid']): ?> <?php if ($status['can_pay']): ?>
<a href="<?php echo esc_url(home_url('/order-payment/?order_id=' . $order_id)); ?>" <a href="<?php echo esc_url(home_url('/order-payment/?order_id=' . $order_id)); ?>"
class="ifnex-btn ifnex-btn-sm ifnex-btn-success"> class="ifnex-btn ifnex-btn-sm ifnex-btn-success">
💳 پرداخت 💳 پرداخت

View File

@ -4,6 +4,8 @@ namespace App\Enums;
enum ShipmentStatus: string enum ShipmentStatus: string
{ {
case PendingApproval = 'pending_approval';
case Approved = 'approved';
case PendingPayment = 'pending_payment'; case PendingPayment = 'pending_payment';
case Cancelled = 'cancelled'; case Cancelled = 'cancelled';
case Processed = 'processed'; case Processed = 'processed';
@ -18,15 +20,17 @@ enum ShipmentStatus: string
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
self::PendingApproval => 'در انتظار تأیید',
self::Approved => 'تأیید شده - در انتظار پرداخت',
self::PendingPayment => 'در انتظار پرداخت', self::PendingPayment => 'در انتظار پرداخت',
self::Cancelled => 'لغو شده', self::Cancelled => 'لغو شده',
self::Processed => 'Processed', self::Processed => 'در حال پردازش',
self::PickedUp => 'Picked Up', self::PickedUp => 'تحویل به حمل‌کننده',
self::InTransit => 'In Transit', self::InTransit => 'در حال حمل',
self::OutForDelivery => 'Out For Delivery', self::OutForDelivery => 'آماده تحویل',
self::Failed => 'Failed', self::Failed => 'تحویل ناموفق',
self::Delivered => 'Delivered', self::Delivered => 'تحویل شده',
self::Returned => 'Returned', self::Returned => 'برگشتی',
self::Archived => 'آرشیو', self::Archived => 'آرشیو',
}; };
} }
@ -34,6 +38,8 @@ enum ShipmentStatus: string
public function color(): string public function color(): string
{ {
return match ($this) { return match ($this) {
self::PendingApproval => 'warning',
self::Approved => 'info',
self::PendingPayment => 'warning', self::PendingPayment => 'warning',
self::Cancelled => 'danger', self::Cancelled => 'danger',
self::Processed => 'gray', self::Processed => 'gray',
@ -49,11 +55,37 @@ enum ShipmentStatus: string
public function isPaid(): bool public function isPaid(): bool
{ {
return $this !== self::PendingPayment; return !in_array($this, [
self::PendingApproval,
self::PendingPayment,
]);
} }
public function canBeCancelledByCustomer(): bool public function canBeCancelledByCustomer(): bool
{ {
return $this === self::PendingPayment; return in_array($this, [
self::PendingApproval,
self::PendingPayment,
]);
} }
}
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;
}
}

View File

@ -0,0 +1,198 @@
<?php
namespace App\Filament\Pages;
use App\Models\Shipment;
use App\Enums\ShipmentStatus;
use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\DB;
class BulkTrackingImport extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-arrow-up-tray';
protected static ?string $navigationGroup = 'مدیریت عملیات';
protected static ?string $navigationLabel = 'ایمپورت گروهی ترکینگ';
protected static ?string $title = 'ایمپورت گروهی وضعیت ترکینگ';
protected static ?int $navigationSort = 20;
protected static string $view = 'filament.pages.bulk-tracking-import';
public ?array $data = [];
public function mount(): void
{
$this->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 = "<div style='font-family: monospace; font-size: 12px; background: #f5f5f5; padding: 10px; border-radius: 5px;'>";
$preview .= "<strong>خط اول (هدر):</strong> " . e($lines[0] ?? '') . "<br>";
$preview .= "<strong>تعداد خطوط:</strong> " . count($lines) . "<br>";
if (isset($lines[1])) {
$preview .= "<br><strong>نمونه (خط دوم):</strong> " . e($lines[1]);
}
$preview .= "</div>";
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();
}
}
}

View File

@ -0,0 +1,174 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\AuditLogResource\Pages;
use App\Models\AuditLog;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class AuditLogResource extends Resource
{
protected static ?string $model = AuditLog::class;
protected static ?string $navigationIcon = 'heroicon-o-clock';
protected static ?string $navigationGroup = 'گزارش‌ها';
protected static ?string $navigationLabel = 'تاریخچه تغییرات';
protected static ?string $modelLabel = 'لاگ تغییرات';
protected static ?string $pluralModelLabel = 'تاریخچه تغییرات';
protected static ?int $navigationSort = 50;
protected static ?string $navigationBadgeColor = 'warning';
public static function form(Form $form): Form
{
return $form->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();
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\AuditLogResource\Pages;
use App\Filament\Resources\AuditLogResource;
use Filament\Resources\Pages\EditRecord;
class EditAuditLog extends EditRecord
{
protected static string $resource = AuditLogResource::class;
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\AuditLogResource\Pages;
use App\Filament\Resources\AuditLogResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListAuditLogs extends ListRecords
{
protected static string $resource = AuditLogResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CommitmentFormResource\Pages;
use App\Models\CommitmentForm;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Forms\Components\FileUpload;
use Illuminate\Support\Facades\Storage;
class CommitmentFormResource extends Resource
{
protected static ?string $model = CommitmentForm::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationLabel = 'فایل‌های تعهدنامه';
protected static ?string $navigationGroup = 'تنظیمات';
protected static ?int $navigationSort = 5;
public static function form(Form $form): Form
{
return $form
->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'),
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\CommitmentFormResource\Pages;
use App\Filament\Resources\CommitmentFormResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCommitmentForm extends CreateRecord
{
protected static string $resource = CommitmentFormResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['uploaded_by'] = auth()->id();
return $data;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CommitmentFormResource\Pages;
use App\Filament\Resources\CommitmentFormResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCommitmentForm extends EditRecord
{
protected static string $resource = CommitmentFormResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CommitmentFormResource\Pages;
use App\Filament\Resources\CommitmentFormResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCommitmentForms extends ListRecords
{
protected static string $resource = CommitmentFormResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,242 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CustomerCreditResource\Pages;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class CustomerCreditResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
protected static ?string $navigationGroup = 'مدیریت مالی';
protected static ?string $navigationLabel = 'اعتبار مشتریان';
protected static ?string $modelLabel = 'اعتبار مشتری';
protected static ?string $pluralModelLabel = 'اعتبار مشتریان';
protected static ?int $navigationSort = 10;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make('اطلاعات مشتری')
->schema([
Forms\Components\TextInput::make('name')
->label('نام')
->disabled(),
Forms\Components\TextInput::make('email')
->label('ایمیل')
->disabled(),
Forms\Components\TextInput::make('phone')
->label('موبایل')
->disabled(),
])->columns(3),
Forms\Components\Section::make('اطلاعات اعتبار')
->schema([
Forms\Components\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 = '<div style="font-size: 12px; line-height: 1.8;">';
foreach ($transactions as $tx) {
$type = match($tx->type) {
'credit_add' => ' افزایش اعتبار',
'credit_use' => ' استفاده از اعتبار',
default => $tx->type,
};
$html .= '<div>' . $type . ': ' . number_format($tx->amount) . ' ریال - ' . $tx->description . '</div>';
}
$html .= '</div>';
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();
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CustomerCreditResource\Pages;
use App\Filament\Resources\CustomerCreditResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCustomerCredit extends EditRecord
{
protected static string $resource = CustomerCreditResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CustomerCreditResource\Pages;
use App\Filament\Resources\CustomerCreditResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCustomerCredits extends ListRecords
{
protected static string $resource = CustomerCreditResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,196 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ShipmentChecklistResource\Pages;
use App\Models\ShipmentChecklist;
use App\Enums\ShipmentStatus;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ShipmentChecklistResource extends Resource
{
protected static ?string $model = ShipmentChecklist::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static ?string $navigationGroup = 'مدیریت عملیات';
protected static ?string $navigationLabel = 'چک‌لیست سفارشات';
protected static ?string $modelLabel = 'چک‌لیست سفارش';
protected static ?string $pluralModelLabel = 'چک‌لیست سفارشات';
protected static ?int $navigationSort = 15;
public static function form(Form $form): Form
{
return $form->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();
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\ShipmentChecklistResource\Pages;
use App\Filament\Resources\ShipmentChecklistResource;
use Filament\Resources\Pages\CreateRecord;
class CreateShipmentChecklist extends CreateRecord
{
protected static string $resource = ShipmentChecklistResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ShipmentChecklistResource\Pages;
use App\Filament\Resources\ShipmentChecklistResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditShipmentChecklist extends EditRecord
{
protected static string $resource = ShipmentChecklistResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ShipmentChecklistResource\Pages;
use App\Filament\Resources\ShipmentChecklistResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListShipmentChecklists extends ListRecords
{
protected static string $resource = ShipmentChecklistResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -212,6 +212,51 @@ class ShipmentResource extends Resource
->label('Cash on Delivery'), ->label('Cash on Delivery'),
])->columns(4), ])->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') Forms\Components\Section::make('Sender Info')
->schema([ ->schema([
Forms\Components\TextInput::make('sender_name') Forms\Components\TextInput::make('sender_name')
@ -406,6 +451,32 @@ class ShipmentResource extends Resource
->actions([ ->actions([
Tables\Actions\ViewAction::make(), Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::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([ ->headerActions([
Action::make('exportCsv') Action::make('exportCsv')

View File

@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CommitmentForm;
use Illuminate\Http\JsonResponse;
class CommitmentFormController extends Controller
{
/**
* دریافت لیست فایل‌های تعهدنامه فعال
* GET /api/v1/commitment-forms
*/
public function index(): JsonResponse
{
$forms = CommitmentForm::query()
->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,
]);
}
}

View File

@ -212,7 +212,7 @@ class CustomerOrderController extends Controller
'awb_no' => $awbNo, 'awb_no' => $awbNo,
'direction' => $validated['direction'], 'direction' => $validated['direction'],
'type' => $validated['type'], 'type' => $validated['type'],
'status' => ShipmentStatus::PendingPayment, 'status' => ShipmentStatus::PendingApproval,
// وزن // وزن
'weight' => $validated['weight'], 'weight' => $validated['weight'],
@ -307,7 +307,7 @@ class CustomerOrderController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'سفارش شما با موفقیت ثبت شد. لطفاً برای تکمیل، پرداخت را انجام دهید.', 'message' => 'سفارش شما با موفقیت ثبت شد. پس از تأیید کارمند، گزینه پرداخت برای شما فعال خواهد شد.',
'data' => $this->formatShipment($shipment->load(['fromCountry', 'toCountry', 'items'])), 'data' => $this->formatShipment($shipment->load(['fromCountry', 'toCountry', 'items'])),
'pricing' => $priceResult, 'pricing' => $priceResult,
], 201); ], 201);
@ -384,12 +384,25 @@ class CustomerOrderController extends Controller
$user = Auth::user(); $user = Auth::user();
$user->load(['wallet']); $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 = [ $stats = [
'total_orders' => Shipment::where('user_id', $user->id)->count(), 'total_orders' => Shipment::where('user_id', $user->id)->count(),
'pending_orders' => Shipment::where('user_id', $user->id) 'pending_approval' => Shipment::where('user_id', $user->id)
->where('status', ShipmentStatus::PendingPayment) ->where('status', ShipmentStatus::PendingApproval)
->count(), ->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) 'active_orders' => Shipment::where('user_id', $user->id)
->whereIn('status', [ ->whereIn('status', [
ShipmentStatus::Processed, ShipmentStatus::Processed,
@ -402,7 +415,8 @@ class CustomerOrderController extends Controller
->where('status', ShipmentStatus::Delivered) ->where('status', ShipmentStatus::Delivered)
->count(), ->count(),
'total_spent' => Shipment::where('user_id', $user->id) 'total_spent' => Shipment::where('user_id', $user->id)
->where('status', '!=', ShipmentStatus::PendingPayment) ->where('status', '!=', ShipmentStatus::PendingApproval)
->where('status', '!=', ShipmentStatus::Approved)
->where('status', '!=', ShipmentStatus::Cancelled) ->where('status', '!=', ShipmentStatus::Cancelled)
->sum('total_fee'), ->sum('total_fee'),
]; ];
@ -420,6 +434,8 @@ class CustomerOrderController extends Controller
'balance' => $user->wallet->balance, 'balance' => $user->wallet->balance,
'is_frozen' => $user->wallet->is_frozen, 'is_frozen' => $user->wallet->is_frozen,
] : null, ] : null,
'account_balance' => $account_balance,
'pending_payments_total' => $pending_payments_total,
'stats' => $stats, 'stats' => $stats,
]); ]);
} }
@ -457,6 +473,8 @@ class CustomerOrderController extends Controller
'color' => $shipment->status?->color(), 'color' => $shipment->status?->color(),
'is_paid' => $shipment->status?->isPaid(), 'is_paid' => $shipment->status?->isPaid(),
'can_cancel' => $shipment->status?->canBeCancelledByCustomer(), 'can_cancel' => $shipment->status?->canBeCancelledByCustomer(),
'can_pay' => $shipment->status?->canBePaid(),
'is_approved' => $shipment->status?->isApproved(),
], ],
'from_country' => $shipment->fromCountry ? [ 'from_country' => $shipment->fromCountry ? [
'id' => $shipment->fromCountry->id, 'id' => $shipment->fromCountry->id,
@ -565,11 +583,11 @@ class CustomerOrderController extends Controller
], 403); ], 403);
} }
// بررسی وضعیت // بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند)
if ($shipment->status !== ShipmentStatus::PendingPayment) { if ($shipment->status !== ShipmentStatus::Approved) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'این سفارش در وضعیت قابل پرداخت نیست.', 'message' => 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.',
], 400); ], 400);
} }
@ -611,11 +629,11 @@ class CustomerOrderController extends Controller
], 403); ], 403);
} }
// بررسی وضعیت // بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند)
if ($shipment->status !== ShipmentStatus::PendingPayment) { if ($shipment->status !== ShipmentStatus::Approved) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'این سفارش در وضعیت قابل پرداخت نیست.', 'message' => 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.',
], 400); ], 400);
} }

View File

@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Shipment;
use App\Models\WalletTransaction;
use App\Enums\ShipmentStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CustomerFinancialController extends Controller
{
/**
* جستجوی مشتری بر اساس نام یا ایمیل
* GET /api/v1/staff/customers/search?q=search_term
*/
public function search(Request $request): JsonResponse
{
$validated = $request->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,
]);
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\KavenegarSmsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class MobileVerificationController extends Controller
{
private KavenegarSmsService $smsService;
public function __construct(KavenegarSmsService $smsService)
{
$this->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);
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Shipment;
use App\Models\ShipmentStatusHistory;
use App\Enums\ShipmentStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class StaffOrderController extends Controller
{
/**
* لیست سفارشات در انتظار تأیید
* GET /api/v1/staff/orders/pending-approval
*/
public function pendingApproval(Request $request): JsonResponse
{
$validated = $request->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');
}
}
}

View File

@ -128,4 +128,56 @@ class ShipmentPdfController extends Controller
return response('PDF generation failed: ' . $e->getMessage(), 500); 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);
}
}
} }

View File

@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use HasFactory;
protected $table = 'audit_logs';
protected $fillable = [
'user_id',
'event',
'auditable_type',
'auditable_id',
'old_values',
'new_values',
'description',
'ip_address',
'user_agent',
];
protected $casts = [
'old_values' => '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(),
]);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CommitmentForm extends Model
{
use SoftDeletes;
protected $fillable = [
'title',
'description',
'file_path',
'file_type',
'file_size',
'direction',
'is_active',
'sort_order',
'uploaded_by',
];
protected $casts = [
'is_active' => '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];
}
}

View File

@ -34,6 +34,16 @@ class Shipment extends Model
'net_dirham', 'net_dirham',
'net_rial', 'net_rial',
'invoice_total_usd', '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
'sender_name', 'sender_name',
'sender_company', 'sender_company',
@ -55,6 +65,8 @@ class Shipment extends Model
// Customs // Customs
'reason_for_export', 'reason_for_export',
'content_description', 'content_description',
// User
'user_id',
]; ];
protected $casts = [ protected $casts = [

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ShipmentChecklist extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'shipment_id',
'user_id',
'status',
'is_completed',
'notes',
'completed_at',
];
protected $casts = [
'is_completed' => '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(),
]);
}
}

View File

@ -18,6 +18,8 @@ class User extends Authenticatable
'password', 'password',
'phone', 'phone',
'role', // فیلد قدیمی - بعداً حذف می‌کنیم 'role', // فیلد قدیمی - بعداً حذف می‌کنیم
'credit_limit', // سقف اعتبار
'credit_used', // مبلغ استفاده شده از اعتبار
]; ];
protected $hidden = [ protected $hidden = [
@ -30,6 +32,8 @@ class User extends Authenticatable
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'credit_limit' => 'decimal:2',
'credit_used' => 'decimal:2',
]; ];
} }
@ -62,6 +66,18 @@ class User extends Authenticatable
return $this->hasRole('customer'); 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 ─────────────────────── // ─── Filament Panel Access ───────────────────────
public function canAccessPanel(\Filament\Panel $panel): bool public function canAccessPanel(\Filament\Panel $panel): bool

View File

@ -0,0 +1,169 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class KavenegarSmsService
{
private string $apiKey;
private string $sender;
public function __construct()
{
$this->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(),
];
}
}
}

View File

@ -87,6 +87,194 @@ class PdfService
return $this->generatePdf($html, 'A5', 'landscape'); 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 (مطمئن‌ترین روش) * تولید بارکد از AWB number base64 embed (مطمئن‌ترین روش)
*/ */

View File

@ -0,0 +1,37 @@
<?php
namespace App\Traits;
use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Model;
trait Auditable
{
protected static function bootAuditable(): void
{
static::created(function (Model $model) {
AuditLog::log('created', $model, [], $model->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();
}
}

View File

@ -35,4 +35,9 @@ return [
], ],
], ],
'kavenegar' => [
'api_key' => env('KAVENEGAR_API_KEY', ''),
'sender' => env('KAVENEGAR_SENDER', '10008566'),
],
]; ];

View File

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// اضافه کردن pending_approval و approved به enum
DB::statement("
ALTER TABLE `shipments`
MODIFY COLUMN `status` ENUM(
'pending_approval',
'approved',
'pending_payment',
'cancelled',
'processed',
'picked_up',
'in_transit',
'out_for_delivery',
'delivered',
'failed',
'returned',
'archived'
) DEFAULT 'pending_approval'
");
// تبدیل سفارشات pending_payment موجود به approved (برای سازگاری)
// سفارشات قدیمی که قبلاً pending_payment بودند و هنوز پرداخت نشده‌اند
// را به approved تغییر می‌دهیم تا مشتری بتواند پرداخت کند
DB::table('shipments')
->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'
");
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('commitment_forms', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipment_checklists', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->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',
]);
});
}
};

26
04_Laravel/read_excel.php Normal file
View File

@ -0,0 +1,26 @@
<?php
require 'vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
$reader = IOFactory::createReader('Xlsx');
$spreadsheet = $reader->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;
}

View File

@ -0,0 +1,44 @@
<x-filament-panels::page>
<form wire:submit="submit">
{{ $this->form }}
<div class="mt-6">
<x-filament::button type="submit" color="primary">
ایمپورت وضعیت ترکینگ
</x-filament::button>
</div>
</form>
<div class="mt-8">
<h3 class="text-lg font-semibold mb-4">راهنما</h3>
<div class="bg-gray-50 p-4 rounded-lg text-sm">
<p class="mb-2"><strong>فرمت فایل CSV:</strong></p>
<ul class="list-disc list-inside space-y-1 mb-4">
<li>خط اول: هدر (.awb_no,status,description)</li>
<li>هر سطر بعدی: یک سفارش</li>
<li><strong>awb_no:</strong> شماره سفارش (الزامی)</li>
<li><strong>status:</strong> وضعیت جدید (اختیاری - اگر خالی باشد از وضعیت پیش‌فرض استفاده می‌شود)</li>
<li><strong>description:</strong> توضیحات (اختیاری)</li>
</ul>
<p class="mb-2"><strong>وضعیت‌های مجاز:</strong></p>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
<span class="bg-yellow-100 text-yellow-800 px-2 py-1 rounded text-xs">pending_approval</span>
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs">approved</span>
<span class="bg-yellow-100 text-yellow-800 px-2 py-1 rounded text-xs">pending_payment</span>
<span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">paid</span>
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs">processed</span>
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs">picked_up</span>
<span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs">in_transit</span>
<span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">out_for_delivery</span>
<span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">delivered</span>
<span class="bg-red-100 text-red-800 px-2 py-1 rounded text-xs">cancelled</span>
</div>
<p class="mt-4"><strong>نمونه فایل:</strong></p>
<pre class="bg-white p-2 rounded mt-1 text-xs">.awb_no,status,description
123456789,in_transit,ارسال شد از تهران
987654321,delivered,تحویل داده شد</pre>
</div>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,353 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: DejaVu Sans, Arial, sans-serif;
font-size: 10px;
color: #000;
background: #fff;
}
.page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
margin: 0 auto;
}
/* ─── HEADER ─── */
.header {
margin-bottom: 20px;
}
.invoice-to {
margin-bottom: 15px;
}
.invoice-to h2 {
font-size: 14px;
color: #333;
margin-bottom: 5px;
}
.invoice-to p {
font-size: 11px;
color: #666;
}
.invoice-to .name {
font-size: 16px;
font-weight: bold;
color: #000;
}
/* ─── INVOICE INFO ─── */
.invoice-info {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.invoice-info-table {
border-collapse: collapse;
font-size: 11px;
}
.invoice-info-table td {
padding: 4px 10px;
border: 1px solid #ccc;
}
.invoice-info-table td:first-child {
font-weight: bold;
background: #f5f5f5;
width: 120px;
}
/* ─── SHIPMENT DETAILS ─── */
.shipment-details {
margin-bottom: 20px;
padding: 10px;
border: 1px solid #ccc;
}
.shipment-details h3 {
font-size: 12px;
margin-bottom: 10px;
color: #333;
}
.details-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.detail-item {
flex: 1;
min-width: 150px;
}
.detail-item .label {
font-size: 9px;
color: #666;
margin-bottom: 2px;
}
.detail-item .value {
font-size: 12px;
font-weight: bold;
color: #000;
}
/* ─── ITEMS TABLE ─── */
.items-section {
margin-bottom: 20px;
}
.items-section h3 {
font-size: 12px;
margin-bottom: 10px;
color: #333;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
th, td {
border: 1px solid #ccc;
padding: 6px 8px;
text-align: left;
}
th {
background: #f5f5f5;
font-weight: bold;
text-align: center;
}
td.text-center { text-align: center; }
td.text-right { text-align: right; }
.total-row {
background: #f9f9f9;
font-weight: bold;
}
.subtotal-row {
background: #fafafa;
}
/* ─── TOTALS ─── */
.totals-section {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.totals-table {
border-collapse: collapse;
font-size: 11px;
width: 350px;
}
.totals-table td {
padding: 6px 10px;
border: 1px solid #ccc;
}
.totals-table td:first-child {
font-weight: bold;
background: #f5f5f5;
width: 180px;
}
.totals-table .grand-total {
background: #333;
color: #fff;
font-size: 13px;
}
/* ─── TERMS & CONDITIONS ─── */
.terms-section {
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #ccc;
}
.terms-section h4 {
font-size: 11px;
margin-bottom: 10px;
color: #333;
}
.terms-section p {
font-size: 9px;
color: #666;
line-height: 1.6;
}
/* ─── SIGNATURE ─── */
.signature-section {
display: flex;
justify-content: flex-end;
margin-top: 30px;
}
.signature-box {
width: 200px;
text-align: center;
}
.signature-box .label {
font-size: 10px;
color: #666;
margin-bottom: 40px;
}
.signature-line {
border-top: 1px solid #000;
padding-top: 5px;
font-size: 10px;
}
/* ─── FOOTER ─── */
.footer {
margin-top: 20px;
padding-top: 10px;
border-top: 2px solid #f37021;
text-align: center;
font-size: 9px;
color: #999;
}
</style>
</head>
<body>
<div class="page">
<!-- INVOICE TO -->
<div class="invoice-to">
<h2>INVOICE TO</h2>
<p class="name">{{ $receiver['name'] }}</p>
<p>{{ $receiver['company'] ?: '' }}</p>
<p>{{ $receiver['address'] }}</p>
<p>{{ $receiver['city'] }}, {{ $receiver['country'] }}</p>
</div>
<!-- INVOICE INFO -->
<div class="invoice-info">
<table class="invoice-info-table">
<tr>
<td>Invoice No.</td>
<td>{{ $invoice_no }}</td>
</tr>
<tr>
<td>Date</td>
<td>{{ $invoice_date }}</td>
</tr>
<tr>
<td>Origin</td>
<td>{{ $shipment->fromCountry?->name }}</td>
</tr>
<tr>
<td>Transport</td>
<td>Air Freight</td>
</tr>
<tr>
<td>Customs</td>
<td>{{ $customs_office ?: 'Imam Khomeini Airport' }}</td>
</tr>
</table>
</div>
<!-- SHIPMENT DETAILS -->
<div class="shipment-details">
<h3>SHIPMENT DETAILS</h3>
<div class="details-grid">
<div class="detail-item">
<div class="label">Actual Weight:</div>
<div class="value">{{ number_format($shipment->weight, 2) }} KG</div>
</div>
<div class="detail-item">
<div class="label">Volume:</div>
<div class="value">{{ $shipment->dimensions ?: '—' }}</div>
</div>
<div class="detail-item">
<div class="label">Volumetric Weight:</div>
<div class="value">{{ number_format($shipment->volumetric_weight ?? 0, 2) }} KG</div>
</div>
<div class="detail-item">
<div class="label">Chargeable Weight:</div>
<div class="value">{{ number_format($shipment->chargeable_weight ?? $shipment->weight, 2) }} KG</div>
</div>
<div class="detail-item">
<div class="label">Goods Nature:</div>
<div class="value">{{ $shipment->content_description ?: 'General Goods' }}</div>
</div>
<div class="detail-item">
<div class="label">Exchange Rate:</div>
<div class="value">{{ number_format($exchange_rate) }} IRR</div>
</div>
</div>
</div>
<!-- ITEMS TABLE -->
<div class="items-section">
<table>
<thead>
<tr>
<th style="width: 40px;">No.</th>
<th>Item Description</th>
<th style="width: 70px;">Unit</th>
<th style="width: 60px;">Qty</th>
<th style="width: 100px;">Unit Price</th>
<th style="width: 120px;">Total (Currency)</th>
<th style="width: 120px;">Total (Rial)</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
@foreach($service_items as $index => $item)
<tr>
<td class="text-center">{{ $index + 1 }}</td>
<td>{{ $item['description'] }}</td>
<td class="text-center">{{ $item['unit'] ?: 'service' }}</td>
<td class="text-center">{{ number_format($item['quantity'], 2) }}</td>
<td class="text-right">{{ $item['unit_price'] > 0 ? number_format($item['unit_price'], 2) : '0.00' }}</td>
<td class="text-right">{{ $item['total_currency'] > 0 ? number_format($item['total_currency'], 2) : '0.00' }}</td>
<td class="text-right">{{ $item['total_rial'] > 0 ? number_format($item['total_rial']) : '0' }}</td>
<td>{{ $item['notes'] ?: '' }}</td>
</tr>
@endforeach
<!-- Freight Subtotal -->
<tr class="subtotal-row">
<td colspan="5" class="text-right">Freight Subtotal</td>
<td class="text-right">{{ number_format($freight_subtotal_currency, 2) }}</td>
<td class="text-right">{{ number_format($freight_subtotal_rial) }}</td>
<td></td>
</tr>
<!-- Clearance Subtotal -->
<tr class="subtotal-row">
<td colspan="5" class="text-right">Clearance Subtotal</td>
<td class="text-right">{{ number_format($clearance_subtotal_currency, 2) }}</td>
<td class="text-right">{{ number_format($clearance_subtotal_rial) }}</td>
<td></td>
</tr>
<!-- Currency Rate -->
<tr class="subtotal-row">
<td colspan="5" class="text-right">Currency Rate:</td>
<td class="text-right" colspan="2">{{ number_format($exchange_rate) }}</td>
<td></td>
</tr>
<!-- TOTAL -->
<tr class="total-row">
<td colspan="5" class="text-right">TOTAL</td>
<td class="text-right">{{ number_format($total_currency, 2) }}</td>
<td class="text-right">{{ number_format($total_rial) }}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<!-- TERMS & CONDITIONS -->
<div class="terms-section">
<h4>TERMS & CONDITIONS</h4>
<p>{{ $terms_and_conditions }}</p>
</div>
<!-- SIGNATURE -->
<div class="signature-section">
<div class="signature-box">
<div class="label">Authorised Signature</div>
<div class="signature-line">IFNEX Logistics</div>
</div>
</div>
<!-- FOOTER -->
<div class="footer">
IFNEX Logistics We Deliver Value | Invoice: {{ $invoice_no }} | Generated: {{ now()->format('Y-m-d H:i') }}
</div>
</div>
</body>
</html>

View File

@ -1,10 +1,14 @@
<?php <?php
use App\Http\Controllers\Api\CustomerFinancialController;
use App\Http\Controllers\Api\CommitmentFormController;
use App\Http\Controllers\Api\MobileVerificationController;
use App\Http\Controllers\Api\Customer\CustomerOrderController; use App\Http\Controllers\Api\Customer\CustomerOrderController;
use App\Http\Controllers\Api\DiscountCodeController; use App\Http\Controllers\Api\DiscountCodeController;
use App\Http\Controllers\Api\MockGatewayController; use App\Http\Controllers\Api\MockGatewayController;
use App\Http\Controllers\Api\PaymentController; use App\Http\Controllers\Api\PaymentController;
use App\Http\Controllers\Api\PricingController; use App\Http\Controllers\Api\PricingController;
use App\Http\Controllers\Api\StaffOrderController;
use App\Http\Controllers\Api\TrackController; use App\Http\Controllers\Api\TrackController;
use App\Http\Controllers\Api\WalletController; use App\Http\Controllers\Api\WalletController;
use App\Http\Middleware\ApiKeyMiddleware; use App\Http\Middleware\ApiKeyMiddleware;
@ -75,6 +79,20 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
Route::post('/orders/{shipment}/pay-wallet', [CustomerOrderController::class, 'payFromWallet']); Route::post('/orders/{shipment}/pay-wallet', [CustomerOrderController::class, 'payFromWallet']);
Route::post('/orders/{shipment}/pay-gateway', [CustomerOrderController::class, 'payViaGateway']); 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::post('/v1/calculate', [PricingController::class, 'calculate']);
Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']); Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']);
Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']); 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']);

View File

@ -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/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/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/label', [ShipmentPdfController::class, 'label'])->name('shipments.pdf.label');
Route::get('/shipments/{shipment}/pdf/import-invoice', [ShipmentPdfController::class, 'importInvoice'])->name('shipments.pdf.import-invoice');
}); });
// صفحات نتیجه پرداخت // صفحات نتیجه پرداخت

259
AGENT.md Normal file
View File

@ -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 برای دانلود استفاده از <a href> با روت مستقیم
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)

View File

@ -1,6 +1,6 @@
# راهنمای استقرار IFNEX # راهنمای استقرار IFNEX
> **آخرین بروزرسانی:** 2026-08-29 > **آخرین بروزرسانی:** 2026-09-03
--- ---
@ -8,8 +8,8 @@
| محیط | دامنه | نقش | | محیط | دامنه | نقش |
|-------|-------|------| |-------|-------|------|
| Production | api.ifnex.vernahost.ir | API لاراول | | Production | system.ifnex.ir | API لاراول |
| Production | ifnex.vernahost.ir | وب‌سایت وردپرس | | Production | ifnex.ir | وب‌سایت وردپرس |
| Local | localhost:8000 | توسعه | | Local | localhost:8000 | توسعه |
--- ---
@ -61,7 +61,7 @@ nano .env
```env ```env
APP_ENV=production APP_ENV=production
APP_DEBUG=false APP_DEBUG=false
APP_URL=https://api.ifnex.vernahost.ir APP_URL=https://system.ifnex.ir
DB_HOST=localhost DB_HOST=localhost
DB_DATABASE=ifnex_db DB_DATABASE=ifnex_db
@ -71,14 +71,18 @@ DB_PASSWORD=STRONG_PASSWORD
# مهم: Bridge API Key باید با وردپرس یکسان باشد # مهم: Bridge API Key باید با وردپرس یکسان باشد
IFNEX_BRIDGE_API_KEY=ifnex-bridge-secret-key-2026-vernasoft 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 # Payment Gateway
ZARINPAL_SANDBOX=false ZARINPAL_SANDBOX=false
ZARINPAL_MERCHANT_ID=YOUR_REAL_MERCHANT_ID ZARINPAL_MERCHANT_ID=YOUR_REAL_MERCHANT_ID
ZARINPAL_CALLBACK_URL=https://api.ifnex.vernahost.ir/api/v1/payment/callback ZARINPAL_CALLBACK_URL=https://system.ifnex.ir/api/v1/payment/callback
ZARINPAL_FRONTEND_SUCCESS_URL=https://ifnex.vernahost.ir/wallet ZARINPAL_FRONTEND_SUCCESS_URL=https://ifnex.ir/wallet
ZARINPAL_FRONTEND_FAILURE_URL=https://ifnex.vernahost.ir/wallet ZARINPAL_FRONTEND_FAILURE_URL=https://ifnex.ir/wallet
# Kavenegar SMS Service
KAVENEGAR_API_KEY=YOUR_KAVENEGAR_API_KEY
KAVENEGAR_SENDER=10008566
``` ```
### ۱.۴ دیتابیس ### ۱.۴ دیتابیس

View File

@ -37,6 +37,16 @@
- **اعلان‌های دیتابیس** برای ادمین و مشتری با badge unread در سایدبار - **اعلان‌های دیتابیس** برای ادمین و مشتری با badge unread در سایدبار
- **پشتیبانی از ۱۹۲ کشور** با پیش‌شماره تلفن استاندارد ISO 3166-1 - **پشتیبانی از ۱۹۲ کشور** با پیش‌شماره تلفن استاندارد ISO 3166-1
- **اعتبارسنجی فرم‌ها** شامل تشخیص خودکار زبان فارسی/انگلیسی و پیش‌شماره خودکار - **اعتبارسنجی فرم‌ها** شامل تشخیص خودکار زبان فارسی/انگلیسی و پیش‌شماره خودکار
- **سیستم تأیید سفارش** با فلوی `pending_approval → approved → paid → processed`
- **فرم تعهدنامه** با قابلیت آپلود فایل توسط مدیر و مشاهده توسط مشتری
- **فاکتور واردات** با طراحی مطابق Sheet ENG Invoice
- **مانده حساب** با نمایش موجودی کیف پول منهای بدهی‌ها
- **وضعیت مالی مشتری** برای مدیر/کارمند با جزئیات بدهی به هر ارز
- **سیستم اعتباردهی (Credit)** با قابلیت افزایش/کاهش توسط مدیر کل
- **چک‌لیست کارمند** برای هر سفارش + تاریخچه مراحل
- **ایمپورت گروهی وضعیت ترکینگ** با فایل CSV
- **سیستم Audit Log** برای لاگ تغییرات مدیر
- **اتصال سرویس اس‌ام‌اس Kavenegar** برای تأیید موبایل و ارسال پیامک
--- ---