diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js
index 49896fe..347b7ee 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js
@@ -4,6 +4,7 @@ jQuery(document).ready(function($) {
var currentStep = 1;
var countries = [];
var priceData = null;
+ var packages = []; // آرایه بستهها
// ─── Load Countries ───
function loadCountries() {
@@ -42,7 +43,6 @@ jQuery(document).ready(function($) {
var $country = $(countrySelectId + ' option:selected');
var code = $country.data('calling-code') || '';
var $phone = $(phoneInputId);
- // حذف کد قدیمی اگر وجود داره
var currentVal = $phone.val().replace(/^\+\d+\s?/, '').trim();
if (code) {
$phone.val(code + ' ' + currentVal).css('border-color', '');
@@ -69,7 +69,7 @@ jQuery(document).ready(function($) {
}
});
- // ─── English-only validation for all text fields ───
+ // ─── English-only validation for text fields ───
function isPersianText(text) {
var cleaned = text.replace(/[\s0-9\-.,،؛:()\/#]/g, '');
return /[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]/.test(cleaned);
@@ -90,7 +90,6 @@ jQuery(document).ready(function($) {
});
}
- // همه فیلدهای متنی باید انگلیسی باشن
setupEnglishField('#ifnex-sender-name', '#ifnex-sender-name-warning');
setupEnglishField('#ifnex-sender-city', '#ifnex-sender-city-warning');
setupEnglishField('#ifnex-sender-address', '#ifnex-sender-address-warning');
@@ -98,6 +97,185 @@ jQuery(document).ready(function($) {
setupEnglishField('#ifnex-receiver-city', '#ifnex-receiver-city-warning');
setupEnglishField('#ifnex-receiver-address', '#ifnex-receiver-address-warning');
+ // ══════════════════════════════════════════════════════════════
+ // ─── Multi-Package Management (جدولی) ───
+ // ══════════════════════════════════════════════════════════════
+
+ // محاسبه وزن حجمی از ابعاد (با پشتیبانی از * و x و ,)
+ function calculateVolumetricWeight(dimensions) {
+ if (!dimensions) return 0;
+ // نرمالسازی: تبدیل x و , به *
+ var normalized = dimensions.replace(/[xX,]/g, '*');
+ var parts = normalized.split('*');
+ if (parts.length !== 3) return 0;
+ var length = parseFloat(parts[0].trim());
+ var width = parseFloat(parts[1].trim());
+ var height = parseFloat(parts[2].trim());
+ if (isNaN(length) || isNaN(width) || isNaN(height)) return 0;
+ if (length <= 0 || width <= 0 || height <= 0) return 0;
+ return Math.round((length * width * height) / 5000 * 1000) / 1000;
+ }
+
+ // نرمالسازی ابعاد به فرمت * (مثلاً 5*6*9)
+ function normalizeDimensions(dimensions) {
+ if (!dimensions) return '';
+ return dimensions.replace(/[xX,]/g, '*').trim();
+ }
+
+ // افزودن یه بسته جدید
+ function addPackage() {
+ var pkgNumber = packages.length + 1;
+ var pkg = {
+ id: 'pkg_' + Date.now() + '_' + pkgNumber,
+ number: pkgNumber,
+ weight: '',
+ dimensions: '',
+ volumetricWeight: 0,
+ description: ''
+ };
+ packages.push(pkg);
+ renderPackage(pkg);
+ updateSummary();
+ }
+
+ // حذف یه بسته
+ function removePackage(pkgId) {
+ if (packages.length <= 1) {
+ alert('حداقل باید یک بسته وجود داشته باشد.');
+ return;
+ }
+ packages = packages.filter(function(p) { return p.id !== pkgId; });
+ // بروزرسانی شماره بستهها
+ packages.forEach(function(p, i) {
+ p.number = i + 1;
+ });
+ renderAllPackages();
+ updateSummary();
+ }
+
+ // رندر یه بسته بهصورت ساده (با فیلدهای جداگانه مثل بقیه فرم)
+ function renderPackage(pkg) {
+ var html = '
-
-
- get_notifications($user_id, 30);
- if (is_wp_error($all_notifications)):
- ?>
-
-
-
-
-
اعلان جدیدی وجود ندارد.
-
-
-
-
+
diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
index 7b14fce..f12b373 100644
--- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
+++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Customer;
use App\Http\Controllers\Controller;
use App\Models\Shipment;
use App\Models\ShipmentItem;
+use App\Models\ShipmentPackage;
use App\Models\Country;
use App\Enums\ShipmentStatus;
use App\Enums\ShipmentDirection;
@@ -16,7 +17,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
-use App\Models\WalletTransaction;
class CustomerOrderController extends Controller
{
@@ -76,11 +76,17 @@ class CustomerOrderController extends Controller
'from_country_id' => ['required', 'exists:countries,id'],
'to_country_id' => ['required', 'exists:countries,id'],
- // وزن و ابعاد
- 'weight' => ['required', 'numeric', 'min:0.1'],
+ // وزن و ابعاد (فیلدهای کلی سفارش - برای backward compatibility)
+ 'weight' => ['nullable', 'numeric', 'min:0.1'],
'volumetric_weight' => ['nullable', 'numeric', 'min:0'],
'dimensions' => ['nullable', 'string', 'max:100'],
+ // بستهها (Multi-Package) — حداقل ۱، حداکثر ۱۰ بسته
+ 'packages' => ['nullable', 'array', 'min:1', 'max:10'],
+ 'packages.*.weight' => ['required_with:packages', 'numeric', 'min:0.1'],
+ 'packages.*.dimensions' => ['nullable', 'string', 'max:50'],
+ 'packages.*.description' => ['nullable', 'string', 'max:500'],
+
// اطلاعات فرستنده
'sender_name' => ['required', 'string', 'max:255'],
'sender_company' => ['nullable', 'string', 'max:255'],
@@ -136,6 +142,29 @@ class CustomerOrderController extends Controller
], 403);
}
+ // محاسبه وزن کل از بستهها (اگر وجود داره)
+ $packages = $validated['packages'] ?? [];
+ $totalWeight = 0;
+ $totalVolumetricWeight = 0;
+
+ if (!empty($packages)) {
+ foreach ($packages as $pkg) {
+ $totalWeight += (float) $pkg['weight'];
+ $volWeight = ShipmentPackage::calculateVolumetricWeight($pkg['dimensions'] ?? null);
+ $totalVolumetricWeight += $volWeight;
+ }
+ // Override وزن کلی سفارش
+ $validated['weight'] = $totalWeight;
+ $validated['volumetric_weight'] = $totalVolumetricWeight;
+ $validated['chargeable_weight'] = max($totalWeight, $totalVolumetricWeight);
+ } else {
+ // حالت قدیمی: یه بسته
+ $validated['chargeable_weight'] = max(
+ (float) $validated['weight'],
+ (float) ($validated['volumetric_weight'] ?? 0)
+ );
+ }
+
// دریافت کشور مقصد برای محاسبه قیمت
$destinationCountry = Country::findOrFail(
$validated['direction'] === 'export'
@@ -229,6 +258,35 @@ class CustomerOrderController extends Controller
'receiver_id_number' => $validated['receiver_id_number'] ?? null,
]);
+ // ذخیره بستهها (Multi-Package)
+ if (!empty($validated['packages'])) {
+ foreach ($validated['packages'] as $index => $pkg) {
+ $volWeight = ShipmentPackage::calculateVolumetricWeight($pkg['dimensions'] ?? null);
+ $chargeable = max((float) $pkg['weight'], $volWeight);
+
+ ShipmentPackage::create([
+ 'shipment_id' => $shipment->id,
+ 'package_number' => $index + 1,
+ 'weight' => $pkg['weight'],
+ 'volumetric_weight' => $volWeight,
+ 'chargeable_weight' => $chargeable,
+ 'dimensions' => $pkg['dimensions'] ?? null,
+ 'description' => $pkg['description'] ?? null,
+ ]);
+ }
+ } else {
+ // حالت قدیمی: یه بسته با وزن کلی سفارش
+ ShipmentPackage::create([
+ 'shipment_id' => $shipment->id,
+ 'package_number' => 1,
+ 'weight' => $validated['weight'],
+ 'volumetric_weight' => $validated['volumetric_weight'] ?? 0,
+ 'chargeable_weight' => $validated['chargeable_weight'],
+ 'dimensions' => $validated['dimensions'] ?? null,
+ 'description' => null,
+ ]);
+ }
+
// ذخیره اقلام گمرکی
if (!empty($validated['items'])) {
foreach ($validated['items'] as $index => $item) {
@@ -372,7 +430,7 @@ class CustomerOrderController extends Controller
*/
public function countries(): JsonResponse
{
- $countries = Country::select('id', 'name', 'iso_code', 'calling_code', 'export_zone_parcel', 'import_zone_parcel')
+ $countries = Country::select('id', 'name', 'iso_code', 'export_zone_parcel', 'import_zone_parcel')
->where('is_active', true)
->orderBy('name')
->get();
@@ -465,62 +523,6 @@ class CustomerOrderController extends Controller
return $data;
}
- /**
- * نوتیفیکیشنهای کاربر
- * GET /api/v1/customer/notifications
- */
- public function notifications(Request $request): JsonResponse
- {
- $user = Auth::user();
-
- $notifications = $user->notifications()
- ->orderBy('created_at', 'desc')
- ->paginate($request->per_page ?? 20);
-
- return response()->json([
- 'success' => true,
- 'data' => collect($notifications->items())->map(function ($notification) {
- $data = $notification->data;
- return [
- 'id' => $notification->id,
- 'type' => $notification->type,
- 'title' => $data['title'] ?? 'اعلان',
- 'body' => $data['message'] ?? '',
- 'data' => $data,
- 'read_at' => $notification->read_at?->toIso8601String(),
- 'created_at' => $notification->created_at->toIso8601String(),
- 'created_at_jalali' => $this->toJalali($notification->created_at),
- ];
- }),
- 'unread_count' => $user->unreadNotifications()->count(),
- ]);
- }
-
- /**
- * خواندن نوتیفیکیشن
- * POST /api/v1/customer/notifications/{notification}/read
- */
- public function markNotificationRead(Request $request, $notification): JsonResponse
- {
- $user = Auth::user();
-
- $notif = $user->notifications()->where('id', $notification)->first();
-
- if (!$notif) {
- return response()->json([
- 'success' => false,
- 'message' => 'نوتیفیکیشن یافت نشد.',
- ], 404);
- }
-
- $notif->markAsRead();
-
- return response()->json([
- 'success' => true,
- 'message' => 'خوانده شد.',
- ]);
- }
-
/**
* تولید شماره AWB
*/
diff --git a/04_Laravel/app/Models/Shipment.php b/04_Laravel/app/Models/Shipment.php
index d11e6b6..37b4369 100644
--- a/04_Laravel/app/Models/Shipment.php
+++ b/04_Laravel/app/Models/Shipment.php
@@ -9,47 +9,61 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Shipment extends Model
{
protected $fillable = [
- 'user_id', 'awb_no', 'forwarder', 'direction', 'type', 'status', 'reason_for_export',
- 'weight', 'volumetric_weight', 'chargeable_weight', 'dimensions',
- 'shipping_price', 'extra_service', 'packing_cost', 'domestic_pickup', 'domestic_delivery',
- 'warehousing_cost', 'vat_amount', 'discount', 'total_fee', 'net_dirham', 'net_rial',
- 'from_country_id', 'to_country_id',
- 'sender_name', 'sender_company', 'sender_phone', 'sender_email',
- 'sender_address', 'sender_city', 'sender_state', 'sender_zip', 'sender_id_number',
- 'receiver_name', 'receiver_company', 'receiver_phone', 'receiver_email',
- 'receiver_address', 'receiver_city', 'receiver_state', 'receiver_zip', 'receiver_id_number',
- 'customer_notes', 'cod_amount', 'declared_value', 'content_description',
+ 'awb_no',
+ 'forwarder',
+ 'direction',
+ 'type',
+ 'status',
+ 'from_country_id',
+ 'to_country_id',
+ // Weight & Dimensions
+ 'weight',
+ 'volumetric_weight',
+ 'chargeable_weight',
+ 'dimensions',
+ // Financial
+ 'shipping_price',
+ 'extra_service',
+ 'domestic_pickup',
+ 'packing_cost',
+ 'domestic_delivery',
+ 'warehousing_cost',
+ 'discount',
+ 'vat_amount',
+ 'total_fee',
+ 'net_dirham',
+ 'net_rial',
+ 'invoice_total_usd',
+ // Sender
+ 'sender_name',
+ 'sender_company',
+ 'sender_phone',
+ 'sender_email',
+ 'sender_address',
+ 'sender_city',
+ 'sender_zip',
+ 'sender_id_number',
+ // Receiver
+ 'receiver_name',
+ 'receiver_company',
+ 'receiver_phone',
+ 'receiver_email',
+ 'receiver_address',
+ 'receiver_city',
+ 'receiver_zip',
+ 'receiver_id_number',
+ // Customs
+ 'reason_for_export',
+ 'content_description',
];
- protected function casts(): array
- {
- return [
- 'direction' => \App\Enums\ShipmentDirection::class,
- 'type' => \App\Enums\ShipmentType::class,
- 'status' => \App\Enums\ShipmentStatus::class,
- 'weight' => 'decimal:2',
- 'volumetric_weight' => 'decimal:2',
- 'chargeable_weight' => 'decimal:2',
- 'shipping_price' => 'decimal:2',
- 'extra_service' => 'decimal:2',
- 'packing_cost' => 'decimal:2',
- 'domestic_pickup' => 'decimal:2',
- 'domestic_delivery' => 'decimal:2',
- 'warehousing_cost' => 'decimal:2',
- 'vat_amount' => 'decimal:2',
- 'discount' => 'decimal:2',
- 'total_fee' => 'decimal:2',
- 'net_dirham' => 'decimal:2',
- 'net_rial' => 'decimal:2',
- 'cod_amount' => 'decimal:2',
- 'declared_value' => 'decimal:2',
- ];
- }
+ protected $casts = [
+ 'weight' => 'decimal:3',
+ 'volumetric_weight' => 'decimal:3',
+ 'chargeable_weight' => 'decimal:3',
+ ];
- public function user(): BelongsTo
- {
- return $this->belongsTo(User::class);
- }
+ // === Relationships ===
public function fromCountry(): BelongsTo
{
@@ -76,28 +90,49 @@ class Shipment extends Model
return $this->hasMany(ShipmentTrackingEvent::class);
}
- public function statusHistories(): HasMany
- {
- return $this->hasMany(ShipmentStatusHistory::class)->orderByDesc('created_at');
- }
-
- public function isParcel(): bool
- {
- return $this->type === \App\Enums\ShipmentType::Parcel;
- }
-
- public function isDocument(): bool
- {
- return $this->type !== \App\Enums\ShipmentType::Parcel;
- }
-
- public function getInvoiceTotalUsdAttribute(): float
- {
- return $this->items->sum('total_usd');
- }
-
+ /**
+ * بستههای مرسوله (Multi-Package)
+ */
public function packages(): HasMany
{
- return $this->hasMany(ShipmentPackage::class)->orderBy('package_no');
+ return $this->hasMany(ShipmentPackage::class);
+ }
+
+ // === Scopes ===
+
+ public function scopeByAwb($query, string $awbNo)
+ {
+ return $query->where('awb_no', $awbNo);
+ }
+
+ public function scopeExport($query)
+ {
+ return $query->where('direction', 'export');
+ }
+
+ public function scopeImport($query)
+ {
+ return $query->where('direction', 'import');
+ }
+
+ // === Helpers ===
+
+ /**
+ * دریافت آخرین رویداد ترکینگ
+ */
+ public function latestTrackingEvent()
+ {
+ return $this->trackingEvents()
+ ->orderBy('event_date', 'desc')
+ ->orderBy('event_time', 'desc')
+ ->first();
+ }
+
+ /**
+ * آیا مرسوله تحویل داده شده؟
+ */
+ public function isDelivered(): bool
+ {
+ return $this->status === 'delivered';
}
}
diff --git a/04_Laravel/app/Models/ShipmentPackage.php b/04_Laravel/app/Models/ShipmentPackage.php
index fb8ef68..c1d0c97 100644
--- a/04_Laravel/app/Models/ShipmentPackage.php
+++ b/04_Laravel/app/Models/ShipmentPackage.php
@@ -8,22 +8,56 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ShipmentPackage extends Model
{
protected $fillable = [
- 'shipment_id', 'package_no', 'weight', 'volumetric_weight',
- 'chargeable_weight', 'dimensions', 'declared_value', 'content_description',
+ 'shipment_id',
+ 'package_number',
+ 'weight',
+ 'volumetric_weight',
+ 'chargeable_weight',
+ 'dimensions',
+ 'description',
];
- protected function casts(): array
- {
- return [
- 'weight' => 'decimal:2',
- 'volumetric_weight' => 'decimal:2',
- 'chargeable_weight' => 'decimal:2',
- 'declared_value' => 'decimal:2',
- ];
- }
+ protected $casts = [
+ 'weight' => 'decimal:3',
+ 'volumetric_weight' => 'decimal:3',
+ 'chargeable_weight' => 'decimal:3',
+ ];
+ /**
+ * رابطه با Shipment
+ */
public function shipment(): BelongsTo
{
return $this->belongsTo(Shipment::class);
}
-}
\ No newline at end of file
+
+ /**
+ * محاسبه وزن قابل محاسبه (ماکزیمم وزن واقعی و حجمی)
+ */
+ public function calculateChargeableWeight(): float
+ {
+ return max((float) $this->weight, (float) $this->volumetric_weight);
+ }
+
+ /**
+ * محاسبه وزن حجمی از ابعاد
+ * فرمول: (طول × عرض × ارتفاع) / 5000
+ */
+ public static function calculateVolumetricWeight(?string $dimensions): float
+ {
+ if (!$dimensions) return 0;
+
+ // پارس کردن ابعاد - فرمتهای ممکن: "25*15*3" یا "25x15x3" یا "25,15,3"
+ $parts = preg_split('/[\*xX,]/', trim($dimensions));
+ if (count($parts) !== 3) return 0;
+
+ $length = (float) trim($parts[0]);
+ $width = (float) trim($parts[1]);
+ $height = (float) trim($parts[2]);
+
+ if ($length <= 0 || $width <= 0 || $height <= 0) return 0;
+
+ // فرمول استاندارد IATA: (L × W × H) / 5000
+ return round(($length * $width * $height) / 5000, 3);
+ }
+}
diff --git a/04_Laravel/database_migrations/2026_08_29_030000_create_shipment_packages_table.php b/04_Laravel/database_migrations/2026_08_29_030000_create_shipment_packages_table.php
new file mode 100644
index 0000000..50d2186
--- /dev/null
+++ b/04_Laravel/database_migrations/2026_08_29_030000_create_shipment_packages_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('shipment_id')->constrained()->cascadeOnDelete();
+ $table->unsignedTinyInteger('package_number')->default(1); // ۱، ۲، ۳، ...
+ $table->decimal('weight', 10, 3)->default(0); // وزن واقعی (kg)
+ $table->decimal('volumetric_weight', 10, 3)->default(0); // وزن حجمی (kg)
+ $table->decimal('chargeable_weight', 10, 3)->default(0); // وزن قابل محاسبه
+ $table->string('dimensions', 50)->nullable(); // ابعاد W*L*H (cm)
+ $table->text('description')->nullable(); // توضیحات بسته
+ $table->timestamps();
+
+ $table->index('shipment_id');
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('shipment_packages');
+ }
+};