From 1c8fb2c9d934f10a237f2abfccf87cdb27cce968 Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Sat, 29 Aug 2026 07:09:25 +0330 Subject: [PATCH] - Add shipment_packages migration and ShipmentPackage model - Add packages() relation to Shipment model - Update CustomerOrderController to accept packages[] array - Auto-calculate volumetric weight from dimensions (L*W*H/5000) - Redesign order form Step 1 with Multi-Package UI - Add package repeater (add/remove packages) - Real-time summary of total weights - Both real weight and dimensions are required - Dimensions normalized to * format (5*6*9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.5.1 — Multi-Package complete" --- .../assets/js/ifnex-order-form.js | 254 +++++++++++++++- .../ifnex-bridge/includes/shortcodes.php | 270 +++--------------- .../Api/Customer/CustomerOrderController.php | 122 ++++---- 04_Laravel/app/Models/Shipment.php | 153 ++++++---- 04_Laravel/app/Models/ShipmentPackage.php | 58 +++- ..._030000_create_shipment_packages_table.php | 32 +++ 6 files changed, 522 insertions(+), 367 deletions(-) create mode 100644 04_Laravel/database_migrations/2026_08_29_030000_create_shipment_packages_table.php 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 = '
'; + html += '
'; + html += '📦 بسته #' + pkg.number + ''; + html += ''; + html += '
'; + html += '
'; + + // وزن واقعی + html += '
'; + html += ''; + html += ''; + html += '
'; + + // ابعاد + html += '
'; + html += ''; + html += ''; + html += '
'; + + // وزن حجمی (محاسبه خودکار - فقط نمایش) + html += '
'; + html += ''; + html += ''; + html += '
'; + + // توضیحات + html += '
'; + html += ''; + html += ''; + html += '
'; + + html += '
'; + html += '
'; + + $('#ifnex-packages-container').append(html); + } + + // رندر همه بسته‌ها + function renderAllPackages() { + $('#ifnex-packages-container').empty(); + packages.forEach(function(pkg) { + renderPackage(pkg); + }); + } + + // بروزرسانی خلاصه وزن‌ها + function updateSummary() { + var totalWeight = 0; + var totalVolumetric = 0; + var hasWeight = false; + + packages.forEach(function(pkg) { + if (pkg.weight && pkg.weight > 0) { + totalWeight += parseFloat(pkg.weight); + hasWeight = true; + } + totalVolumetric += pkg.volumetricWeight || 0; + }); + + var chargeable = Math.max(totalWeight, totalVolumetric); + + $('#ifnex-package-count').text(packages.length); + $('#ifnex-total-weight').text(totalWeight.toFixed(2)); + $('#ifnex-total-volumetric').text(totalVolumetric.toFixed(2)); + $('#ifnex-chargeable-weight').text(chargeable.toFixed(2)); + + if (packages.length > 0) { + $('#ifnex-packages-summary').show(); + } else { + $('#ifnex-packages-summary').hide(); + } + } + + // Event: افزودن بسته + $('#ifnex-add-package').on('click', function() { + addPackage(); + }); + + // Event: تغییر وزن بسته + $(document).on('input', '.ifnex-pkg-weight', function() { + var pkgId = $(this).data('pkg-id'); + var weight = $(this).val(); + var pkg = packages.find(function(p) { return p.id === pkgId; }); + if (pkg) { + pkg.weight = weight; + updateSummary(); + } + }); + + // Event: تغییر ابعاد بسته (محاسبه وزن حجمی + نرمال‌سازی) + $(document).on('input', '.ifnex-pkg-dimensions', function() { + var pkgId = $(this).data('pkg-id'); + var rawDimensions = $(this).val(); + var normalized = normalizeDimensions(rawDimensions); + var volWeight = calculateVolumetricWeight(normalized); + var pkg = packages.find(function(p) { return p.id === pkgId; }); + if (pkg) { + pkg.dimensions = normalized; + pkg.volumetricWeight = volWeight; + // آپدیت کردن فیلد وزن حجمی (readonly) + $('.ifnex-pkg-volumetric-display[data-pkg-id="' + pkgId + '"]').val(volWeight.toFixed(3)); + updateSummary(); + } + }); + + // Event: تغییر توضیحات بسته + $(document).on('input', '.ifnex-pkg-description', function() { + var pkgId = $(this).data('pkg-id'); + var desc = $(this).val(); + var pkg = packages.find(function(p) { return p.id === pkgId; }); + if (pkg) { + pkg.description = desc; + } + }); + + // Event: حذف بسته + $(document).on('click', '.ifnex-remove-package', function() { + var pkgId = $(this).data('pkg-id'); + removePackage(pkgId); + }); + // ─── Step Navigation ─── function goToStep(step) { currentStep = step; @@ -125,11 +303,39 @@ jQuery(document).ready(function($) { function validateStep1() { var ok = true; - ['#ifnex-direction', '#ifnex-type', '#ifnex-from-country', '#ifnex-to-country', '#ifnex-weight'].forEach(function(sel) { + ['#ifnex-direction', '#ifnex-type', '#ifnex-from-country', '#ifnex-to-country'].forEach(function(sel) { if (!$(sel).val()) { $(sel).css('border-color', '#ef4444'); ok = false; } else { $(sel).css('border-color', ''); } }); - if (!ok) showFormError('لطفاً همه فیلدهای مرحله ۱ را تکمیل کنید.'); + + // بررسی بسته‌ها + if (packages.length === 0) { + showFormError('حداقل باید یک بسته اضافه کنید.'); + ok = false; + } else { + var allValid = true; + packages.forEach(function(pkg) { + var hasWeight = pkg.weight && parseFloat(pkg.weight) > 0; + var hasVolumetric = pkg.volumetricWeight && pkg.volumetricWeight > 0; + if (!hasWeight) { + $('.ifnex-pkg-weight[data-pkg-id="' + pkg.id + '"]').css('border-color', '#ef4444'); + allValid = false; + } else { + $('.ifnex-pkg-weight[data-pkg-id="' + pkg.id + '"]').css('border-color', ''); + } + if (!hasVolumetric) { + $('.ifnex-pkg-dimensions[data-pkg-id="' + pkg.id + '"]').css('border-color', '#ef4444'); + allValid = false; + } else { + $('.ifnex-pkg-dimensions[data-pkg-id="' + pkg.id + '"]').css('border-color', ''); + } + }); + if (!allValid) { + showFormError('برای هر بسته، وزن واقعی و ابعاد را وارد کنید.'); + ok = false; + } + } + return ok; } @@ -175,6 +381,27 @@ jQuery(document).ready(function($) { function buildOrderData() { var $fromOpt = $('#ifnex-from-country option:selected'); var $toOpt = $('#ifnex-to-country option:selected'); + + // محاسبه وزن‌های کل + var totalWeight = 0; + var totalVolumetric = 0; + packages.forEach(function(pkg) { + if (pkg.weight && parseFloat(pkg.weight) > 0) { + totalWeight += parseFloat(pkg.weight); + } + totalVolumetric += pkg.volumetricWeight || 0; + }); + var chargeableWeight = Math.max(totalWeight, totalVolumetric); + + // ساخت آرایه بسته‌ها برای ارسال به API + var packagesArray = packages.map(function(pkg) { + return { + weight: parseFloat(pkg.weight) || 0, + dimensions: pkg.dimensions || '', + description: pkg.description || '' + }; + }); + return { direction: $('#ifnex-direction').val(), type: $('#ifnex-type').val(), @@ -183,8 +410,10 @@ jQuery(document).ready(function($) { from_country_iso: $fromOpt.data('iso'), to_country_iso: $toOpt.data('iso'), country_iso: $('#ifnex-direction').val() === 'export' ? $toOpt.data('iso') : $fromOpt.data('iso'), - weight: parseFloat($('#ifnex-weight').val()) || 0, - volumetric_weight: parseFloat($('#ifnex-volumetric-weight').val()) || 0, + weight: totalWeight, + volumetric_weight: totalVolumetric, + chargeable_weight: chargeableWeight, + packages: packagesArray, discount_code: $('#ifnex-discount-code').val() || '', sender_name: $('#ifnex-sender-name').val(), sender_phone: $('#ifnex-sender-phone').val(), @@ -199,6 +428,11 @@ jQuery(document).ready(function($) { function renderPriceSummary(data) { var html = '
'; + html += '
تعداد بسته‌ها:' + packages.length + '
'; + html += '
مجموع وزن واقعی:' + numberFormat(data.weight || 0) + ' kg
'; + html += '
مجموع وزن حجمی:' + numberFormat(data.volumetric_weight || 0) + ' kg
'; + html += '
وزن قابل محاسبه:' + numberFormat(data.chargeable_weight || 0) + ' kg
'; + html += '
'; html += '
قیمت پایه:' + (data.base_price || '0') + ' درهم
'; html += '
ناخالص:' + (data.net_dirham || '0') + ' درهم
'; html += '
ریال:' + numberFormat(data.net_rial) + ' ریال
'; @@ -213,7 +447,7 @@ jQuery(document).ready(function($) { } function numberFormat(n) { - return new Intl.NumberFormat('fa-IR').format(n || 0); + return new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 2 }).format(n || 0); } function showFormError(msg) { @@ -253,5 +487,7 @@ jQuery(document).ready(function($) { // ─── Init ─── if ($('#ifnex-order-form').length) { loadCountries(); + // افزودن بسته اول به‌صورت پیش‌فرض + addPackage(); } -}); \ No newline at end of file +}); diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php index bc4f4d5..21e7915 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php @@ -126,114 +126,6 @@ function ifnex_wallet_balance_shortcode($atts) { برای شارژ کیف پول، باید وارد شوید.

'; - } - - $bridge = new IFNEX_User_Bridge(); - $balance = $bridge->get_wallet_balance(get_current_user_id()); - $current_balance = is_wp_error($balance) ? 0 : ($balance['balance'] ?? 0); - - ob_start(); - ?> -
-
-
- موجودی فعلی: - ریال -
-
- -
-

💳 شارژ کیف پول

-

برای شارژ کیف پول، مبلغ مورد نظر را وارد کنید. شما به درگاه پرداخت زرین‌پال منتقل خواهید شد.

- -
- - - - - -
- -
-
- - -
-
- - - - -
-
- - - کشور مقصد *
-
- - -
-
- - + +

📦 بسته‌های مرسوله

+

+ اگر چند بسته دارید، برای هرکدوم اطلاعات جداگانه وارد کنید. وزن واقعی و ابعاد هر دو اجباری هستند (وزن حجمی خودکار محاسبه می‌شود). +

+ +
+ +
+ + + + +
@@ -655,67 +569,23 @@ function ifnex_order_form_shortcode($atts) {
- +

👤 اطلاعات فرستنده

-
- - - -
-
- - -
-
- - - -
-
- - - -
+
+
+
+
-

📮 اطلاعات گیرنده

-
- - - -
-
- - -
-
- - - -
-
- - - -
+
+
+
+
-
@@ -956,11 +826,8 @@ function ifnex_order_payment_shortcode($atts) { btn.prop('disabled', false).text('انتقال به درگاه بانکی'); $('#ifnex-payment-message') .addClass('ifnex-error-box') - var errMsg = res.data; - if (typeof errMsg === 'object') { - errMsg = errMsg.message || JSON.stringify(errMsg); - } - $('#ifnex-payment-message').addClass('ifnex-error-box').text('خطا: ' + (errMsg || 'خطا در اتصال به درگاه')).show(); + .text('خطا: ' + (res.data || 'خطا در اتصال به درگاه')) + .show(); } }, error: function() { @@ -1270,7 +1137,6 @@ function ifnex_icon($name, $size = 20) { 'truck' => '', 'check' => '', 'clock' => '', - 'bell' => '', ]; $path = $icons[$name] ?? $icons['dashboard']; @@ -1298,7 +1164,7 @@ function ifnex_customer_dashboard_shortcode($atts) { } $tab = sanitize_key($_GET['tab'] ?? 'dashboard'); - $allowed_tabs = ['dashboard', 'orders', 'new-order', 'wallet', 'transactions', 'notifications', 'tracking', 'profile']; + $allowed_tabs = ['dashboard', 'orders', 'new-order', 'wallet', 'transactions', 'tracking', 'profile']; if (!in_array($tab, $allowed_tabs)) $tab = 'dashboard'; $bridge = new IFNEX_User_Bridge(); @@ -1326,14 +1192,12 @@ function ifnex_customer_dashboard_shortcode($atts) { 'wallet' => ['icon' => 'wallet', 'label' => 'کیف پول'], 'transactions' => ['icon' => 'chart', 'label' => 'تراکنش‌ها'], 'tracking' => ['icon' => 'tracking', 'label' => 'رهگیری مرسوله'], - 'notifications' => ['icon' => 'bell', 'label' => 'اعلان‌ها'], 'profile' => ['icon' => 'user', 'label' => 'پروفایل'], ]; $tab_titles = [ 'dashboard' => 'داشبورد', 'orders' => 'سفارشات من', 'new-order' => 'ثبت سفارش جدید', - 'wallet' => 'کیف پول', 'transactions' => 'تراکنش‌ها', 'notifications' => 'اعلان‌ها', - 'tracking' => 'رهگیری مرسوله', 'profile' => 'پروفایل', + 'wallet' => 'کیف پول', 'transactions' => 'تراکنش‌ها', 'tracking' => 'رهگیری مرسوله', 'profile' => 'پروفایل', ]; ob_start(); @@ -1353,26 +1217,6 @@ function ifnex_customer_dashboard_shortcode($atts) { - - get_notifications($user_id, 1); - if (!is_wp_error($notif_result)) { - $unread_count = $notif_result['unread_count'] ?? 0; - } - ?> - $item): ?> - - - - 0): ?> - 9 ? '9+' : $unread_count; ?> - - - -
@@ -1467,35 +1311,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
- - - 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'); + } +};