From 4ee1d27d44509606d67624a62850433a140f5153 Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Mon, 10 Aug 2026 06:09:56 +0330 Subject: [PATCH] feat: Complete customer ordering system (Phase 3) Laravel Backend: - Add AuthController for Sanctum token login/logout - Add CustomerOrderController with 6 endpoints (profile, countries, orders CRUD, cancel) - Extend ShipmentStatus enum with pending_payment/cancelled - Add SampleShippingRatesSeeder (10 zones, 3 service types) - Zone-assign 232 countries (Gulf=1, Asia=2, EU=3, East=4, US=5) - Add migration to extend shipments.status enum - Add test-api.php automated test suite (5 tests) WordPress Frontend: - Add [ifnex_order_form] multi-step wizard (4 steps) - Add [ifnex_orders_list] with status filter & cancel action - Add [ifnex_user_profile] with wallet & stats - Add ifnex-orders.css (RTL-ready, IFNEX theme) - Add ifnex-order-form.js (step navigation + AJAX) - Add AJAX handlers (countries, calculate, submit, cancel) - Fix plugin asset paths with dirname(__FILE__) --- .../ifnex-bridge/assets/css/ifnex-orders.css | 214 ++++++++ .../assets/js/ifnex-order-form.js | 200 +++++++ .../ifnex-bridge/includes/shortcodes.php | 416 +++++++++++++++ .../ifnex-bridge/includes/user-bridge.php | 245 ++++++++- 04_Laravel/app/Enums/ShipmentStatus.php | 83 ++- .../Filament/Resources/ShipmentResource.php | 17 + .../Http/Controllers/Api/AuthController.php | 68 +++ .../Api/Customer/CustomerOrderController.php | 492 ++++++++++++++++++ .../app/Http/Controllers/Controller.php | 5 + ...10_020410_update_shipments_status_enum.php | 47 ++ .../seeders/SampleShippingRatesSeeder.php | 214 ++++++++ 04_Laravel/routes/api.php | 55 +- 04_Laravel/test-api.php | 136 +++++ 13 files changed, 2171 insertions(+), 21 deletions(-) create mode 100644 03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css create mode 100644 03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js create mode 100644 04_Laravel/app/Http/Controllers/Api/AuthController.php create mode 100644 04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php create mode 100644 04_Laravel/database/migrations/2026_08_10_020410_update_shipments_status_enum.php create mode 100644 04_Laravel/database/seeders/SampleShippingRatesSeeder.php create mode 100644 04_Laravel/test-api.php diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css new file mode 100644 index 0000000..52af5ff --- /dev/null +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css @@ -0,0 +1,214 @@ +/* ═══════════════════════════════════════════ + IFNEX Orders & Order Form Styles + ═══════════════════════════════════════════ */ + +:root { + --ifnex-primary: #f59e0b; + --ifnex-primary-dark: #d97706; + --ifnex-dark: #1a1a2e; + --ifnex-gray: #6b7280; + --ifnex-light: #f9fafb; + --ifnex-border: #e5e7eb; + --ifnex-success: #10b981; + --ifnex-danger: #ef4444; + --ifnex-info: #3b82f6; + --ifnex-warning: #f59e0b; + --ifnex-radius: 12px; +} + +/* ─── General Buttons ─── */ +.ifnex-btn { + display: inline-block; + padding: 10px 20px; + border-radius: 8px; + font-weight: 600; + text-decoration: none !important; + border: none; + cursor: pointer; + transition: all 0.2s; + font-size: 14px; +} +.ifnex-btn:hover { opacity: 0.9; transform: translateY(-1px); } +.ifnex-btn-primary { background: var(--ifnex-primary); color: #fff; } +.ifnex-btn-success { background: var(--ifnex-success); color: #fff; } +.ifnex-btn-danger { background: var(--ifnex-danger); color: #fff; } +.ifnex-btn-info { background: var(--ifnex-info); color: #fff; } +.ifnex-btn-sm { padding: 6px 12px; font-size: 12px; } + +/* ─── Badges ─── */ +.ifnex-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} +.ifnex-badge-success { background: #d1fae5; color: #065f46; } +.ifnex-badge-danger { background: #fee2e2; color: #991b1b; } +.ifnex-badge-info { background: #dbeafe; color: #1e40af; } +.ifnex-badge-warning { background: #fef3c7; color: #92400e; } +.ifnex-badge-gray { background: #f3f4f6; color: #374151; } +.ifnex-badge-primary { background: #fef3c7; color: #92400e; } +.ifnex-badge-purple { background: #ede9fe; color: #5b21b6; } + +/* ═══ Orders List ═══ */ +.ifnex-orders-list { max-width: 1200px; margin: 0 auto; padding: 20px 0; } +.ifnex-orders-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 20px; flex-wrap: wrap; gap: 12px; +} +.ifnex-orders-header h2 { margin: 0; color: var(--ifnex-dark); } + +.ifnex-filter-bar { + display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; + padding: 12px; background: #fff; border-radius: var(--ifnex-radius); + border: 1px solid var(--ifnex-border); +} +.ifnex-filter-bar a { + padding: 6px 14px; border-radius: 999px; font-size: 13px; + text-decoration: none !important; color: var(--ifnex-gray); + border: 1px solid var(--ifnex-border); transition: all 0.2s; +} +.ifnex-filter-bar a:hover { border-color: var(--ifnex-primary); color: var(--ifnex-primary); } +.ifnex-filter-bar a.active { + background: var(--ifnex-primary); color: #fff; border-color: var(--ifnex-primary); +} + +.ifnex-orders-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 20px; +} +.ifnex-order-card { + background: #fff; border-radius: var(--ifnex-radius); + border: 1px solid var(--ifnex-border); padding: 20px; + transition: box-shadow 0.2s; +} +.ifnex-order-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.08); } + +.ifnex-order-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--ifnex-border); +} +.ifnex-awb code { + background: var(--ifnex-light); padding: 4px 8px; border-radius: 6px; + font-size: 13px; color: var(--ifnex-dark); font-weight: 700; +} + +.ifnex-order-route { + display: flex; align-items: center; gap: 12px; margin-bottom: 16px; +} +.ifnex-route-from, .ifnex-route-to { flex: 1; } +.ifnex-route-arrow { color: var(--ifnex-primary); font-size: 18px; } +.ifnex-label { display: block; font-size: 11px; color: var(--ifnex-gray); margin-bottom: 2px; } +.ifnex-country { font-weight: 600; color: var(--ifnex-dark); } + +.ifnex-order-details { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; + margin-bottom: 16px; padding: 12px; background: var(--ifnex-light); + border-radius: 8px; +} +.ifnex-detail-item { font-size: 13px; } +.ifnex-price { font-weight: 700; color: var(--ifnex-primary-dark); } + +.ifnex-order-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.ifnex-empty-state { + text-align: center; padding: 60px 20px; background: #fff; + border-radius: var(--ifnex-radius); border: 1px dashed var(--ifnex-border); +} +.ifnex-empty-state p { font-size: 18px; color: var(--ifnex-gray); margin-bottom: 20px; } + +.ifnex-pagination { display: flex; gap: 6px; justify-content: center; margin-top: 24px; } +.ifnex-pagination a { + padding: 6px 12px; border-radius: 6px; text-decoration: none !important; + border: 1px solid var(--ifnex-border); color: var(--ifnex-gray); font-size: 13px; +} +.ifnex-pagination a.active { background: var(--ifnex-primary); color: #fff; border-color: var(--ifnex-primary); } + +/* ═══ Order Form (Multi-step) ═══ */ +.ifnex-order-form-container { + max-width: 900px; margin: 0 auto; padding: 20px 0; +} + +.ifnex-steps { + display: flex; justify-content: space-between; margin-bottom: 32px; + position: relative; +} +.ifnex-steps::before { + content: ''; position: absolute; top: 20px; left: 10%; right: 10%; + height: 2px; background: var(--ifnex-border); z-index: 0; +} +.ifnex-step { + position: relative; z-index: 1; text-align: center; flex: 1; + font-size: 13px; color: var(--ifnex-gray); +} +.ifnex-step span { + display: inline-flex; align-items: center; justify-content: center; + width: 40px; height: 40px; border-radius: 50%; background: #fff; + border: 2px solid var(--ifnex-border); margin-bottom: 6px; + font-weight: 700; transition: all 0.3s; +} +.ifnex-step.active span, .ifnex-step.done span { + background: var(--ifnex-primary); border-color: var(--ifnex-primary); color: #fff; +} +.ifnex-step.active { color: var(--ifnex-primary-dark); font-weight: 700; } + +.ifnex-form-step { display: none; } +.ifnex-form-step.active { display: block; animation: ifnexFadeIn 0.3s; } +@keyframes ifnexFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } } + +.ifnex-form-card { + background: #fff; border-radius: var(--ifnex-radius); + border: 1px solid var(--ifnex-border); padding: 28px; +} +.ifnex-form-card h3 { margin-top: 0; color: var(--ifnex-dark); } + +.ifnex-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.ifnex-form-grid .full { grid-column: 1 / -1; } +@media (max-width: 640px) { .ifnex-form-grid { grid-template-columns: 1fr; } } + +.ifnex-field label { + display: block; font-size: 13px; font-weight: 600; + color: var(--ifnex-dark); margin-bottom: 6px; +} +.ifnex-field input, .ifnex-field select, .ifnex-field textarea { + width: 100%; padding: 10px 12px; border: 1px solid var(--ifnex-border); + border-radius: 8px; font-size: 14px; background: #fff; + transition: border-color 0.2s; +} +.ifnex-field input:focus, .ifnex-field select:focus, .ifnex-field textarea:focus { + outline: none; border-color: var(--ifnex-primary); + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15); +} + +.ifnex-form-nav { + display: flex; justify-content: space-between; margin-top: 24px; gap: 12px; +} + +/* ─── Price Summary ─── */ +.ifnex-price-summary { + background: var(--ifnex-light); border-radius: 8px; padding: 20px; +} +.ifnex-price-row { + display: flex; justify-content: space-between; padding: 8px 0; + font-size: 14px; border-bottom: 1px dashed var(--ifnex-border); +} +.ifnex-price-row.total { + border-bottom: none; font-size: 18px; font-weight: 800; + color: var(--ifnex-primary-dark); padding-top: 12px; +} + +.ifnex-error-box { + background: #fee2e2; color: #991b1b; padding: 12px 16px; + border-radius: 8px; margin-bottom: 16px; font-size: 14px; +} +.ifnex-success-box { + background: #d1fae5; color: #065f46; padding: 20px; + border-radius: 8px; text-align: center; font-size: 16px; +} + +.ifnex-login-prompt { + max-width: 500px; margin: 40px auto; text-align: center; + background: #fff; padding: 40px; border-radius: var(--ifnex-radius); + border: 1px solid var(--ifnex-border); +} \ No newline at end of file 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 new file mode 100644 index 0000000..824c429 --- /dev/null +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js @@ -0,0 +1,200 @@ +jQuery(document).ready(function($) { + 'use strict'; + + var currentStep = 1; + var countries = []; + var priceData = null; + + // ─── Load Countries ─── + function loadCountries() { + $.ajax({ + url: ifnex_ajax.ajax_url, + method: 'POST', + data: { action: 'ifnex_get_countries', nonce: ifnex_ajax.nonce }, + success: function(res) { + if (res.success) { + countries = res.data; + populateCountrySelects(); + } + } + }); + } + + function populateCountrySelects() { + var $from = $('#ifnex-from-country'); + var $to = $('#ifnex-to-country'); + $from.empty().append(''); + $to.empty().append(''); + $.each(countries, function(i, c) { + var opt = ''; + $from.append(opt); + $to.append(opt); + }); + } + + // ─── Step Navigation ─── + function goToStep(step) { + currentStep = step; + $('.ifnex-form-step').removeClass('active'); + $('.ifnex-form-step[data-step="' + step + '"]').addClass('active'); + $('.ifnex-step').each(function() { + var s = parseInt($(this).data('step')); + $(this).toggleClass('active', s === step); + $(this).toggleClass('done', s < step); + }); + $('.ifnex-order-form-container')[0].scrollIntoView({ behavior: 'smooth' }); + } + + $('.ifnex-next-step').on('click', function() { + var next = currentStep + 1; + if (currentStep === 1 && !validateStep1()) return; + if (currentStep === 2 && !validateStep2()) return; + if (currentStep === 3) { calculatePrice(); return; } + goToStep(next); + }); + + $('.ifnex-prev-step').on('click', function() { + goToStep(currentStep - 1); + }); + + function validateStep1() { + var ok = true; + ['#ifnex-direction', '#ifnex-type', '#ifnex-from-country', '#ifnex-to-country', '#ifnex-weight'].forEach(function(sel) { + if (!$(sel).val()) { $(sel).css('border-color', '#ef4444'); ok = false; } + else { $(sel).css('border-color', ''); } + }); + if (!ok) showFormError('لطفاً همه فیلدهای مرحله ۱ را تکمیل کنید.'); + return ok; + } + + function validateStep2() { + var ok = true; + ['#ifnex-sender-name', '#ifnex-sender-phone', '#ifnex-sender-address', + '#ifnex-receiver-name', '#ifnex-receiver-phone', '#ifnex-receiver-address'].forEach(function(sel) { + if (!$(sel).val()) { $(sel).css('border-color', '#ef4444'); ok = false; } + else { $(sel).css('border-color', ''); } + }); + if (!ok) showFormError('لطفاً اطلاعات فرستنده و گیرنده را کامل کنید.'); + return ok; + } + + // ─── Calculate Price (Step 3 → 4) ─── + function calculatePrice() { + $('#ifnex-calc-loading').show(); + $('#ifnex-calc-error').hide(); + + var data = buildOrderData(); + + $.ajax({ + url: ifnex_ajax.ajax_url, + method: 'POST', + data: { action: 'ifnex_calculate_price', nonce: ifnex_ajax.nonce, data: data }, + success: function(res) { + $('#ifnex-calc-loading').hide(); + if (res.success) { + priceData = res.data; + renderPriceSummary(res.data); + goToStep(4); + } else { + showCalcError(res.data || 'خطا در محاسبه قیمت'); + } + }, + error: function() { + $('#ifnex-calc-loading').hide(); + showCalcError('خطا در ارتباط با سرور'); + } + }); + } + + function buildOrderData() { + var direction = $('#ifnex-direction').val(); + var toIso = $('#ifnex-to-country option:selected').data('iso'); + var fromIso = $('#ifnex-from-country option:selected').data('iso'); + return { + direction: direction, + type: $('#ifnex-type').val(), + from_country_id: parseInt($('#ifnex-from-country').val()), + to_country_id: parseInt($('#ifnex-to-country').val()), + country_iso: (direction === 'export') ? toIso : fromIso, + weight: parseFloat($('#ifnex-weight').val()) || 0, + volumetric_weight: parseFloat($('#ifnex-volumetric-weight').val()) || 0, + sender_name: $('#ifnex-sender-name').val(), + sender_phone: $('#ifnex-sender-phone').val(), + sender_address: $('#ifnex-sender-address').val(), + sender_city: $('#ifnex-sender-city').val(), + receiver_name: $('#ifnex-receiver-name').val(), + receiver_phone: $('#ifnex-receiver-phone').val(), + receiver_address: $('#ifnex-receiver-address').val(), + receiver_city: $('#ifnex-receiver-city').val(), + discount_code: $('#ifnex-discount-code').val() || null + }; + } + + function renderPriceSummary(p) { + var html = ''; + html += priceRow('قیمت پایه (درهم)', p.base_price); + html += priceRow('خالص (ریال)', p.net_rial); + html += priceRow('بسته‌بندی', p.packing_cost); + if (p.discount_amount > 0) html += priceRow('تخفیف', -p.discount_amount); + html += priceRow('مالیات (VAT)', p.vat_amount); + html += '
مبلغ قابل پرداخت' + formatNum(p.total_fee) + ' ریال
'; + $('#ifnex-price-summary').html(html); + } + + function priceRow(label, val) { + return '
' + label + '' + formatNum(val) + '
'; + } + + function formatNum(n) { + return Number(n).toLocaleString('fa-IR'); + } + + function showFormError(msg) { + $('#ifnex-form-error').text(msg).show(); + setTimeout(function() { $('#ifnex-form-error').fadeOut(); }, 4000); + } + function showCalcError(msg) { + $('#ifnex-calc-error').text(msg).show(); + } + + // ─── Submit Order (Step 4) ─── + $('#ifnex-submit-order').on('click', function() { + var btn = $(this); + btn.prop('disabled', true).text('در حال ثبت سفارش...'); + $('#ifnex-submit-error').hide(); + + var data = buildOrderData(); + + $.ajax({ + url: ifnex_ajax.ajax_url, + method: 'POST', + data: { action: 'ifnex_submit_order', nonce: ifnex_ajax.nonce, data: data }, + success: function(res) { + btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش'); + if (res.success) { + var d = res.data.data; + $('#ifnex-order-form').html( + '
' + + '

🎉 سفارش شما با موفقیت ثبت شد!

' + + '

شماره پیگیری: ' + d.awb_no + '

' + + '

مبلغ قابل پرداخت: ' + formatNum(d.total_fee) + ' ریال

' + + '

وضعیت: ' + d.status.label + '

' + + 'مشاهده سفارشات من' + + '
' + ); + } else { + $('#ifnex-submit-error').text(res.data || 'خطا در ثبت سفارش').show(); + } + }, + error: function() { + btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش'); + $('#ifnex-submit-error').text('خطا در ارتباط با سرور').show(); + } + }); + }); + + // ─── Init ── + if ($('#ifnex-order-form').length) { + loadCountries(); + } +}); \ 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 09ca608..38ed335 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php @@ -189,3 +189,419 @@ function ifnex_transactions_shortcode($atts) { برای مشاهده پروفایل، باید وارد شوید.

'; + } + + $atts = shortcode_atts(array( + 'show_stats' => 'yes', + 'show_wallet' => 'yes', + 'show_recent_orders' => 'yes', + ), $atts); + + $bridge = new IFNEX_User_Bridge(); + $user_id = get_current_user_id(); + + $profile = $bridge->get_customer_profile($user_id); + + if (is_wp_error($profile)) { + return '

' . esc_html($profile->get_error_message()) . '

'; + } + + ob_start(); + ?> +
+ +
+

👤

+
+

ایمیل:

+

تلفن:

+

عضویت از:

+
+
+ + + +
+

💰 کیف پول

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

📊 آمار سفارشات

+
+
+
+
کل سفارشات
+
+
+
+
در انتظار پرداخت
+
+
+
+
در حال پردازش
+
+
+
+
تحویل شده
+
+
+ 0): ?> +

+ مجموع خرید: + ریال +

+ +
+ + + + +
+ برای مشاهده سفارشات، باید وارد شوید.

'; + } + + $atts = shortcode_atts(array( + 'per_page' => 10, + 'status' => '', + ), $atts); + + $bridge = new IFNEX_User_Bridge(); + $user_id = get_current_user_id(); + + $result = $bridge->get_customer_orders($user_id, intval($atts['per_page']), $atts['status']); + + if (is_wp_error($result)) { + return '

' . esc_html($result->get_error_message()) . '

'; + } + + $orders = $result['data'] ?? []; + $pagination = $result['pagination'] ?? []; + + ob_start(); + ?> +
+
+

📦 سفارشات من

+ + ➕ ثبت سفارش جدید + +
+ + + + + +
+

📭 هنوز سفارشی ثبت نکرده‌اید.

+ + اولین سفارش خود را ثبت کنید + +
+ +
+ +
+
+
+ AWB: + +
+ + + +
+ +
+
+ از: + + + +
+
+
+ به: + + + +
+
+ +
+
+ وزن: + kg +
+
+ مبلغ: + + ریال + +
+
+ تاریخ: + +
+
+ +
+ + 👁️ جزئیات + + + + + + + + + 💳 پرداخت + + +
+
+ +
+ + 1): ?> +
+ + + + + +
+ + +
+ + + +
+

🔐 ورود لازم است

+

برای ثبت سفارش، ابتدا باید وارد حساب کاربری خود شوید.

+ ورود به حساب +
+ +
+ + +
+
۱
مسیر و بسته
+
۲
اطلاعات تماس
+
۳
بررسی قیمت
+
۴
تأیید نهایی
+
+ + + + +
+
+

📦 مشخصات مرسوله

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

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

+
+
+
+
+
+
+

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

+
+
+
+
+
+
+
+ + +
+
+
+ + +
+
+

🏷️ کد تخفیف (اختیاری)

+
+
+
+ + +
+ + +
+
+
+ + +
+
+

✅ تأیید نهایی سفارش

+
+ +
+ + +
+
+
+
+ api_url . '/sanctum/token'; + //$url = $this->api_url . '/sanctum/token'; + $url = $this->api_url . '/auth/login'; + $args = array( 'headers' => array( 'Content-Type' => 'application/json', @@ -218,4 +220,245 @@ class IFNEX_User_Bridge { return $data; } + + // ══════════════════════════════════════════════════════════════ + // Customer Orders API (جدید) + // ══════════════════════════════════════════════════════════════ + + /** + * دریافت پروفایل و آمار کاربر + */ + public function get_customer_profile($user_id) { + $result = $this->authenticated_request($user_id, '/customer/profile'); + + if (is_wp_error($result)) { + return $result; + } + + return $result; + } + + /** + * دریافت لیست کشورها + */ + public function get_countries($user_id) { + $result = $this->authenticated_request($user_id, '/customer/countries'); + + if (is_wp_error($result)) { + return $result; + } + + return $result['data'] ?? []; + } + + /** + * دریافت لیست سفارشات کاربر + */ + public function get_customer_orders($user_id, $per_page = 15, $status = null) { + $params = ['per_page' => $per_page]; + + if ($status) { + $params['status'] = $status; + } + + $result = $this->authenticated_request($user_id, '/customer/orders', 'GET', $params); + + if (is_wp_error($result)) { + return $result; + } + + return $result; + } + + /** + * دریافت جزئیات یک سفارش + */ + public function get_customer_order($user_id, $shipment_id) { + $result = $this->authenticated_request($user_id, "/customer/orders/{$shipment_id}"); + + if (is_wp_error($result)) { + return $result; + } + + return $result; + } + + /** + * ثبت سفارش جدید + */ + public function create_customer_order($user_id, $order_data) { + $result = $this->authenticated_request($user_id, '/customer/orders', 'POST', $order_data); + + if (is_wp_error($result)) { + return $result; + } + + return $result; + } + + /** + * لغو سفارش + */ + public function cancel_customer_order($user_id, $shipment_id) { + $result = $this->authenticated_request($user_id, "/customer/orders/{$shipment_id}/cancel", 'POST'); + + if (is_wp_error($result)) { + return $result; + } + + return $result; + } } + +// ══════════════════════════════════════════════════════════════ +// AJAX Handler برای لغو سفارش +// ══════════════════════════════════════════════════════════════ + +add_action('wp_ajax_ifnex_cancel_order', 'ifnex_cancel_order_ajax'); + +function ifnex_cancel_order_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + + if (!is_user_logged_in()) { + wp_send_json_error('باید وارد شوید.'); + } + + $order_id = intval($_POST['order_id'] ?? 0); + if (!$order_id) { + wp_send_json_error('شناسه سفارش نامعتبر است.'); + } + + $bridge = new IFNEX_User_Bridge(); + $result = $bridge->cancel_customer_order(get_current_user_id(), $order_id); + + if (is_wp_error($result)) { + wp_send_json_error($result->get_error_message()); + } + + wp_send_json_success('سفارش لغو شد.'); +} + +// Enqueue AJAX script +add_action('wp_enqueue_scripts', 'ifnex_enqueue_ajax_script'); + +function ifnex_enqueue_ajax_script() { + global $post; + + if (is_a($post, 'WP_Post') && has_shortcode($post->post_content, 'ifnex_orders_list')) { + wp_enqueue_script('jquery'); + wp_localize_script('jquery', 'ifnex_ajax', array( + 'ajax_url' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('ifnex_ajax_nonce'), + )); + } +} + +// ══════════════════════════════════════════════════════════════ +// AJAX Handlers برای فرم سفارش +// ══════════════════════════════════════════════════════════════ + +add_action('wp_ajax_ifnex_get_countries', 'ifnex_get_countries_ajax'); +function ifnex_get_countries_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.'); + + $bridge = new IFNEX_User_Bridge(); + $result = $bridge->get_countries(get_current_user_id()); + + if (is_wp_error($result)) wp_send_json_error($result->get_error_message()); + wp_send_json_success($result); +} + +add_action('wp_ajax_ifnex_calculate_price', 'ifnex_calculate_price_ajax'); +function ifnex_calculate_price_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.'); + + $data = $_POST['data'] ?? []; + if (empty($data)) wp_send_json_error('داده‌ای ارسال نشده.'); + + // تبدیل direction برای PriceCalculator + $payload = array( + 'direction' => ($data['direction'] === 'export') ? 'Outbound' : 'Inbound', + 'type' => $data['type'], + 'country_iso' => $data['country_iso'], + 'weight' => floatval($data['weight']), + 'volumetric_weight' => floatval($data['volumetric_weight'] ?? 0), + ); + if (!empty($data['discount_code'])) $payload['discount_code'] = $data['discount_code']; + + $api_url = rtrim(get_option('ifnex_api_url', 'http://localhost:8000/api/v1'), '/'); + $response = wp_remote_post($api_url . '/calculate', array( + 'headers' => array('Content-Type' => 'application/json', 'Accept' => 'application/json'), + 'body' => json_encode($payload), + 'timeout' => 30, + )); + + if (is_wp_error($response)) wp_send_json_error($response->get_error_message()); + + $body = json_decode(wp_remote_retrieve_body($response), true); + if ($response['response']['code'] !== 200) { + wp_send_json_error($body['message'] ?? 'خطا در محاسبه قیمت'); + } + wp_send_json_success($body); +} + +add_action('wp_ajax_ifnex_submit_order', 'ifnex_submit_order_ajax'); +function ifnex_submit_order_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.'); + + $data = $_POST['data'] ?? []; + if (empty($data)) wp_send_json_error('داده‌ای ارسال نشده.'); + + $bridge = new IFNEX_User_Bridge(); + $result = $bridge->create_customer_order(get_current_user_id(), $data); + + if (is_wp_error($result)) wp_send_json_error($result->get_error_message()); + if (isset($result['success']) && !$result['success']) { + wp_send_json_error($result['message'] ?? 'خطا در ثبت سفارش'); + } + wp_send_json_success($result); +} + +// Enqueue scripts & styles برای شورت‌کدهای جدید +add_action('wp_enqueue_scripts', 'ifnex_enqueue_order_assets'); +function ifnex_enqueue_order_assets() { + global $post; + if (!is_a($post, 'WP_Post')) return; + + $has_order_shortcodes = + has_shortcode($post->post_content, 'ifnex_orders_list') || + has_shortcode($post->post_content, 'ifnex_order_form') || + has_shortcode($post->post_content, 'ifnex_user_profile'); + + if ($has_order_shortcodes) { + // ✅ اصلاح: dirname(__FILE__) یک سطح بالاتر می‌رود + $plugin_url = plugin_dir_url(dirname(__FILE__)); + + // Load CSS + wp_enqueue_style( + 'ifnex-orders-css', + $plugin_url . 'assets/css/ifnex-orders.css', + array(), + '1.2.0' //版本号 changed برای شکستن cache + ); + + // Load jQuery & JS + wp_enqueue_script('jquery'); + wp_enqueue_script( + 'ifnex-order-form-js', + $plugin_url . 'assets/js/ifnex-order-form.js', + array('jquery'), + '1.2.0', //版本号 changed + true + ); + + // Pass data to JS + wp_localize_script('ifnex-order-form-js', 'ifnex_ajax', array( + 'ajax_url' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('ifnex_ajax_nonce'), + 'orders_url' => home_url('/my-orders/'), + )); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Enums/ShipmentStatus.php b/04_Laravel/app/Enums/ShipmentStatus.php index 76a837d..a567aa9 100644 --- a/04_Laravel/app/Enums/ShipmentStatus.php +++ b/04_Laravel/app/Enums/ShipmentStatus.php @@ -4,11 +4,78 @@ namespace App\Enums; enum ShipmentStatus: string { - case Processed = 'processed'; - case PickedUp = 'picked_up'; - case InTransit = 'in_transit'; - case OutForDelivery = 'out_for_delivery'; - case Failed = 'failed'; - case Delivered = 'delivered'; - case Returned = 'returned'; -} + // ─── وضعیت‌های مشتری (قبل از پرداخت) ─── + case PendingPayment = 'pending_payment'; // در انتظار پرداخت + case Cancelled = 'cancelled'; // لغو شده توسط مشتری + + // ─── وضعیت‌های عملیاتی (بعد از پرداخت) ─── + case Processed = 'processed'; // پردازش شده (ادمین تأیید کرده) + case PickedUp = 'picked_up'; // تحویل گرفته شده + case InTransit = 'in_transit'; // در حال حمل + case OutForDelivery = 'out_for_delivery'; // در مسیر تحویل + case Delivered = 'delivered'; // تحویل داده شده + case Failed = 'failed'; // ناموفق + case Returned = 'returned'; // برگشتی + + /** + * لیبل فارسی + */ + public function label(): string + { + return match($this) { + self::PendingPayment => 'در انتظار پرداخت', + self::Cancelled => 'لغو شده', + self::Processed => 'پردازش شده', + self::PickedUp => 'تحویل گرفته شده', + self::InTransit => 'در حال حمل', + self::OutForDelivery => 'در مسیر تحویل', + self::Delivered => 'تحویل داده شده', + self::Failed => 'ناموفق', + self::Returned => 'برگشتی', + }; + } + + /** + * رنگ badge در Filament + */ + public function color(): string + { + return match($this) { + self::PendingPayment => 'warning', + self::Cancelled => 'gray', + self::Processed => 'info', + self::PickedUp => 'info', + self::InTransit => 'primary', + self::OutForDelivery => 'purple', + self::Delivered => 'success', + self::Failed => 'danger', + self::Returned => 'warning', + }; + } + + /** + * آیا این وضعیت به معنای "پرداخت شده" است؟ + */ + public function isPaid(): bool + { + return in_array($this, [ + self::Processed, + self::PickedUp, + self::InTransit, + self::OutForDelivery, + self::Delivered, + self::Failed, + self::Returned, + ]); + } + + /** + * آیا این وضعیت قابل لغو توسط مشتری است؟ + */ + public function canBeCancelledByCustomer(): bool + { + return in_array($this, [ + self::PendingPayment, + ]); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/ShipmentResource.php b/04_Laravel/app/Filament/Resources/ShipmentResource.php index 47820b0..fa15419 100644 --- a/04_Laravel/app/Filament/Resources/ShipmentResource.php +++ b/04_Laravel/app/Filament/Resources/ShipmentResource.php @@ -45,6 +45,21 @@ class ShipmentResource extends Resource 'PARCEL' => 'Parcel', ]) ->required(), + + Forms\Components\Select::make('status') + ->options([ + 'pending_payment' => 'در انتظار پرداخت', + 'cancelled' => 'لغو شده', + 'processed' => 'Processed', + 'picked_up' => 'Picked Up', + 'in_transit' => 'In Transit', + 'out_for_delivery' => 'Out For Delivery', + 'failed' => 'Failed', + 'delivered' => 'Delivered', + 'returned' => 'Returned', + ]) + ->default('processed') + ->required(), Forms\Components\Select::make('status') ->options([ 'processed' => 'Processed', @@ -301,6 +316,8 @@ class ShipmentResource extends Resource ->filters([ Tables\Filters\SelectFilter::make('status') ->options([ + 'pending_payment' => 'در انتظار پرداخت', + 'cancelled' => 'لغو شده', 'processed' => 'Processed', 'picked_up' => 'Picked Up', 'in_transit' => 'In Transit', diff --git a/04_Laravel/app/Http/Controllers/Api/AuthController.php b/04_Laravel/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..23816ab --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,68 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'token_name' => ['nullable', 'string', 'max:100'], + ]); + + $user = User::where('email', $validated['email'])->first(); + + if (!$user || !Hash::check($validated['password'], $user->password)) { + return response()->json([ + 'success' => false, + 'message' => 'ایمیل یا رمز عبور اشتباه است.', + ], 401); + } + + // بررسی فعال بودن کاربر + if (isset($user->is_active) && !$user->is_active) { + return response()->json([ + 'success' => false, + 'message' => 'حساب کاربری شما غیرفعال است.', + ], 403); + } + + $tokenName = $validated['token_name'] ?? 'api-token'; + $token = $user->createToken($tokenName)->plainTextToken; + + return response()->json([ + 'success' => true, + 'token' => $token, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ], + ]); + } + + /** + * خروج و حذف توکن فعلی + * POST /api/v1/auth/logout + */ + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'خروج با موفقیت انجام شد.', + ]); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php new file mode 100644 index 0000000..ff7b7f8 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php @@ -0,0 +1,492 @@ +validate([ + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'status' => ['nullable', 'string'], + 'page' => ['nullable', 'integer', 'min:1'], + ]); + + $user = Auth::user(); + $query = Shipment::query() + ->where('user_id', $user->id) + ->with(['fromCountry', 'toCountry', 'items']) + ->orderBy('created_at', 'desc'); + + if (!empty($validated['status'])) { + $query->where('status', $validated['status']); + } + + $shipments = $query->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/customer/orders + */ + public function store(Request $request): JsonResponse + { + try { + $validated = $request->validate([ + // مسیر و نوع + 'direction' => ['required', 'in:export,import'], + 'type' => ['required', 'in:DOC_NORMAL,DOC_ECONOMY,PARCEL'], + 'from_country_id' => ['required', 'exists:countries,id'], + 'to_country_id' => ['required', 'exists:countries,id'], + + // وزن و ابعاد + 'weight' => ['required', 'numeric', 'min:0.1'], + 'volumetric_weight' => ['nullable', 'numeric', 'min:0'], + 'dimensions' => ['nullable', 'string', 'max:100'], + + // اطلاعات فرستنده + 'sender_name' => ['required', 'string', 'max:255'], + 'sender_company' => ['nullable', 'string', 'max:255'], + 'sender_phone' => ['required', 'string', 'max:50'], + 'sender_email' => ['nullable', 'email', 'max:255'], + 'sender_address' => ['required', 'string', 'max:1000'], + 'sender_city' => ['nullable', 'string', 'max:255'], + 'sender_zip' => ['nullable', 'string', 'max:20'], + 'sender_id_number' => ['nullable', 'string', 'max:50'], + + // اطلاعات گیرنده + 'receiver_name' => ['required', 'string', 'max:255'], + 'receiver_company' => ['nullable', 'string', 'max:255'], + 'receiver_phone' => ['required', 'string', 'max:50'], + 'receiver_email' => ['nullable', 'email', 'max:255'], + 'receiver_address' => ['required', 'string', 'max:1000'], + 'receiver_city' => ['nullable', 'string', 'max:255'], + 'receiver_zip' => ['nullable', 'string', 'max:20'], + 'receiver_id_number' => ['nullable', 'string', 'max:50'], + + // خدمات اضافی + 'extra_service' => ['nullable', 'numeric', 'min:0'], + 'packing_cost' => ['nullable', 'numeric', 'min:0'], + 'domestic_pickup' => ['nullable', 'numeric', 'min:0'], + 'domestic_delivery' => ['nullable', 'numeric', 'min:0'], + 'warehousing_cost' => ['nullable', 'numeric', 'min:0'], + + // تخفیف + 'discount_code' => ['nullable', 'string', 'max:50'], + + // اقلام گمرکی + 'items' => ['nullable', 'array', 'max:9'], + 'items.*.description' => ['required_with:items', 'string', 'max:500'], + 'items.*.hs_code' => ['required_with:items', 'string', 'max:20'], + 'items.*.quantity' => ['required_with:items', 'integer', 'min:1'], + 'items.*.unit_price' => ['required_with:items', 'numeric', 'min:0'], + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'message' => 'خطا در اعتبارسنجی اطلاعات', + 'errors' => $e->errors(), + ], 422); + } + + $user = Auth::user(); + + // بررسی اینکه کاربر کیف پول فعال دارد + if (!$user->wallet || $user->wallet->is_frozen) { + return response()->json([ + 'success' => false, + 'message' => 'کیف پول شما غیرفعال است. لطفاً با پشتیبانی تماس بگیرید.', + ], 403); + } + + // دریافت کشور مقصد برای محاسبه قیمت + $destinationCountry = Country::findOrFail( + $validated['direction'] === 'export' + ? $validated['to_country_id'] + : $validated['from_country_id'] + ); + + // محاسبه قیمت + try { + $pricingData = [ + 'direction' => $validated['direction'] === 'export' ? 'Outbound' : 'Inbound', + 'type' => $validated['type'], + 'country_iso' => $destinationCountry->iso_code, + 'weight' => (float) $validated['weight'], + 'volumetric_weight' => (float) ($validated['volumetric_weight'] ?? 0), + 'extra_service' => (float) ($validated['extra_service'] ?? 0), + 'packing_cost' => (float) ($validated['packing_cost'] ?? 0), + 'domestic_pickup' => (float) ($validated['domestic_pickup'] ?? 0), + 'domestic_delivery' => (float) ($validated['domestic_delivery'] ?? 0), + 'warehousing_cost' => (float) ($validated['warehousing_cost'] ?? 0), + 'discount_code' => $validated['discount_code'] ?? null, + ]; + + $priceResult = $this->calculator->calculate($pricingData); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'خطا در محاسبه قیمت: ' . $e->getMessage(), + ], 400); + } + + // ساخت شماره AWB (فرمت: IFN-YYYY-XXXXX) + $awbNo = $this->generateAwbNumber(); + + // ذخیره سفارش + try { + $shipment = DB::transaction(function () use ($validated, $user, $priceResult, $awbNo) { + $chargeableWeight = max( + (float) $validated['weight'], + (float) ($validated['volumetric_weight'] ?? 0) + ); + + $shipment = Shipment::create([ + 'user_id' => $user->id, + 'awb_no' => $awbNo, + 'direction' => $validated['direction'], + 'type' => $validated['type'], + 'status' => ShipmentStatus::PendingPayment, + + // وزن + 'weight' => $validated['weight'], + 'volumetric_weight' => $validated['volumetric_weight'] ?? null, + 'chargeable_weight' => $chargeableWeight, + 'dimensions' => $validated['dimensions'] ?? null, + + // کشورها + 'from_country_id' => $validated['from_country_id'], + 'to_country_id' => $validated['to_country_id'], + + // اطلاعات مالی (از محاسبه) + 'shipping_price' => $priceResult['net_dirham'], + 'extra_service' => $priceResult['extra_service'], + 'packing_cost' => $priceResult['packing_cost'], + 'domestic_pickup' => $priceResult['domestic_pickup'], + 'domestic_delivery' => $priceResult['domestic_delivery'], + 'warehousing_cost' => $priceResult['warehousing_cost'], + 'vat_amount' => $priceResult['vat_amount'], + 'discount' => $priceResult['discount_amount'], + 'total_fee' => $priceResult['total_fee'], + 'net_dirham' => $priceResult['net_dirham'], + 'net_rial' => $priceResult['net_rial'], + + // فرستنده + 'sender_name' => $validated['sender_name'], + 'sender_company' => $validated['sender_company'] ?? null, + 'sender_phone' => $validated['sender_phone'], + 'sender_email' => $validated['sender_email'] ?? null, + 'sender_address' => $validated['sender_address'], + 'sender_city' => $validated['sender_city'] ?? null, + 'sender_zip' => $validated['sender_zip'] ?? null, + 'sender_id_number' => $validated['sender_id_number'] ?? null, + + // گیرنده + 'receiver_name' => $validated['receiver_name'], + 'receiver_company' => $validated['receiver_company'] ?? null, + 'receiver_phone' => $validated['receiver_phone'], + 'receiver_email' => $validated['receiver_email'] ?? null, + 'receiver_address' => $validated['receiver_address'], + 'receiver_city' => $validated['receiver_city'] ?? null, + 'receiver_zip' => $validated['receiver_zip'] ?? null, + 'receiver_id_number' => $validated['receiver_id_number'] ?? null, + ]); + + // ذخیره اقلام گمرکی + if (!empty($validated['items'])) { + foreach ($validated['items'] as $index => $item) { + ShipmentItem::create([ + 'shipment_id' => $shipment->id, + 'row_number' => $index + 1, + 'description' => $item['description'], + 'hs_code' => $item['hs_code'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total_usd' => $item['quantity'] * $item['unit_price'], + ]); + } + } + + return $shipment; + }); + + return response()->json([ + 'success' => true, + 'message' => 'سفارش شما با موفقیت ثبت شد. لطفاً برای تکمیل، پرداخت را انجام دهید.', + 'data' => $this->formatShipment($shipment->load(['fromCountry', 'toCountry', 'items'])), + 'pricing' => $priceResult, + ], 201); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'خطا در ثبت سفارش: ' . $e->getMessage(), + ], 500); + } + } + + /** + * جزئیات یک سفارش + * GET /api/v1/customer/orders/{shipment} + */ + public function show(Shipment $shipment): JsonResponse + { + $user = Auth::user(); + + // بررسی مالکیت + if ($shipment->user_id !== $user->id) { + return response()->json([ + 'success' => false, + 'message' => 'شما به این سفارش دسترسی ندارید.', + ], 403); + } + + $shipment->load(['fromCountry', 'toCountry', 'items', 'trackingEvents']); + + return response()->json([ + 'success' => true, + 'data' => $this->formatShipment($shipment, detailed: true), + ]); + } + + /** + * لغو سفارش (فقط در وضعیت pending_payment) + * POST /api/v1/customer/orders/{shipment}/cancel + */ + public function cancel(Shipment $shipment): JsonResponse + { + $user = Auth::user(); + + if ($shipment->user_id !== $user->id) { + return response()->json([ + 'success' => false, + 'message' => 'شما به این سفارش دسترسی ندارید.', + ], 403); + } + + if (!$shipment->status->canBeCancelledByCustomer()) { + return response()->json([ + 'success' => false, + 'message' => 'این سفارش در وضعیتی نیست که بتوان آن را لغو کرد.', + ], 400); + } + + $shipment->update(['status' => ShipmentStatus::Cancelled]); + + return response()->json([ + 'success' => true, + 'message' => 'سفارش با موفقیت لغو شد.', + 'data' => $this->formatShipment($shipment->fresh()), + ]); + } + + /** + * پروفایل و آمار کاربر + * GET /api/v1/customer/profile + */ + public function profile(Request $request): JsonResponse + { + $user = Auth::user(); + $user->load(['wallet']); + + // آمار سفارشات + $stats = [ + 'total_orders' => Shipment::where('user_id', $user->id)->count(), + 'pending_orders' => Shipment::where('user_id', $user->id) + ->where('status', ShipmentStatus::PendingPayment) + ->count(), + 'active_orders' => Shipment::where('user_id', $user->id) + ->whereIn('status', [ + ShipmentStatus::Processed, + ShipmentStatus::PickedUp, + ShipmentStatus::InTransit, + ShipmentStatus::OutForDelivery, + ]) + ->count(), + 'delivered_orders' => Shipment::where('user_id', $user->id) + ->where('status', ShipmentStatus::Delivered) + ->count(), + 'total_spent' => Shipment::where('user_id', $user->id) + ->where('status', '!=', ShipmentStatus::PendingPayment) + ->where('status', '!=', ShipmentStatus::Cancelled) + ->sum('total_fee'), + ]; + + return response()->json([ + 'success' => true, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'phone' => $user->phone, + 'member_since' => $user->created_at->format('Y-m-d'), + ], + 'wallet' => $user->wallet ? [ + 'balance' => $user->wallet->balance, + 'is_frozen' => $user->wallet->is_frozen, + ] : null, + 'stats' => $stats, + ]); + } + + /** + * لیست کشورها برای فرم + * GET /api/v1/customer/countries + */ + public function countries(): JsonResponse + { + $countries = Country::select('id', 'name', 'iso_code', 'export_zone_parcel', 'import_zone_parcel') + ->where('is_active', true) + ->orderBy('name') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $countries, + ]); + } + + /** + * فرمت کردن Shipment برای خروجی API + */ + private function formatShipment(Shipment $shipment, bool $detailed = false): array + { + $data = [ + '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(), + 'is_paid' => $shipment->status?->isPaid(), + 'can_cancel' => $shipment->status?->canBeCancelledByCustomer(), + ], + '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, + 'weight' => $shipment->weight, + 'chargeable_weight' => $shipment->chargeable_weight, + 'total_fee' => $shipment->total_fee, + 'net_rial' => $shipment->net_rial, + 'net_dirham' => $shipment->net_dirham, + 'created_at' => $shipment->created_at->toIso8601String(), + 'created_at_jalali' => $this->toJalali($shipment->created_at), + ]; + + if ($detailed) { + $data['sender'] = [ + 'name' => $shipment->sender_name, + 'company' => $shipment->sender_company, + 'phone' => $shipment->sender_phone, + 'email' => $shipment->sender_email, + 'address' => $shipment->sender_address, + 'city' => $shipment->sender_city, + ]; + $data['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, + ]; + $data['financial'] = [ + 'shipping_price' => $shipment->shipping_price, + 'extra_service' => $shipment->extra_service, + 'packing_cost' => $shipment->packing_cost, + 'domestic_pickup' => $shipment->domestic_pickup, + 'domestic_delivery' => $shipment->domestic_delivery, + 'warehousing_cost' => $shipment->warehousing_cost, + 'vat_amount' => $shipment->vat_amount, + 'discount' => $shipment->discount, + 'total_fee' => $shipment->total_fee, + ]; + $data['items'] = $shipment->items->map(fn($item) => [ + 'row' => $item->row_number, + 'description' => $item->description, + 'hs_code' => $item->hs_code, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'total' => $item->total_usd, + ]); + $data['tracking_events'] = $shipment->trackingEvents->map(fn($event) => [ + 'date' => $event->event_date, + 'description' => $event->event_description, + 'location' => $event->location, + ]); + } + + return $data; + } + + /** + * تولید شماره AWB + */ + private function generateAwbNumber(): string + { + do { + $number = 'IFN-' . date('Y') . '-' . str_pad(random_int(10000, 99999), 5, '0', STR_PAD_LEFT); + } while (Shipment::where('awb_no', $number)->exists()); + + return $number; + } + + /** + * تبدیل تاریخ به شمسی + */ + 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'); + } + } +} \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Controller.php b/04_Laravel/app/Http/Controllers/Controller.php index 8677cd5..62b4969 100644 --- a/04_Laravel/app/Http/Controllers/Controller.php +++ b/04_Laravel/app/Http/Controllers/Controller.php @@ -6,3 +6,8 @@ abstract class Controller { // } + +class CustomerOrderController +{ + // ... +} \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_08_10_020410_update_shipments_status_enum.php b/04_Laravel/database/migrations/2026_08_10_020410_update_shipments_status_enum.php new file mode 100644 index 0000000..cff78fa --- /dev/null +++ b/04_Laravel/database/migrations/2026_08_10_020410_update_shipments_status_enum.php @@ -0,0 +1,47 @@ +whereIn('status', ['pending_payment', 'cancelled']) + ->update(['status' => 'processed']); + + DB::statement(" + ALTER TABLE `shipments` + MODIFY COLUMN `status` ENUM( + 'processed', + 'picked_up', + 'in_transit', + 'out_for_delivery', + 'delivered', + 'failed', + 'returned' + ) DEFAULT 'processed' + "); + } +}; \ No newline at end of file diff --git a/04_Laravel/database/seeders/SampleShippingRatesSeeder.php b/04_Laravel/database/seeders/SampleShippingRatesSeeder.php new file mode 100644 index 0000000..d76c115 --- /dev/null +++ b/04_Laravel/database/seeders/SampleShippingRatesSeeder.php @@ -0,0 +1,214 @@ + str_starts_with($col, 'zone_')); + $zoneCount = count($zoneColumns); + + $this->command->info("📊 Found {$zoneCount} zones in shipping_rates table"); + $this->command->info(" Zones: " . implode(', ', $zoneColumns)); + $this->command->info(''); + + // ─── نرخ پایه برای هر zone (PARCEL Export) ────────── + // قیمت‌ها به درهم برای وزن‌های مختلف + // Zone 1: نزدیک‌ترین (Gulf) + // Zone 2-5: متوسط تا دور + // Zone 6+: خیلی دور (با ۲۰٪ افزایش نسبت به zone قبلی) + + $baseParcelRates = [ + 0.5 => [25, 35, 50, 65, 85], + 1.0 => [40, 55, 80, 100, 130], + 2.0 => [65, 90, 130, 165, 210], + 5.0 => [120, 170, 240, 310, 400], + 10.0 => [200, 290, 410, 530, 680], + 20.0 => [350, 510, 720, 930, 1200], + 30.0 => [480, 700, 990, 1280, 1650], + ]; + + $this->command->info('📦 Creating Export Parcel rates...'); + foreach ($baseParcelRates as $weight => $zones) { + $rateData = [ + 'direction' => 'export', + 'type' => 'PARCEL', + 'weight' => $weight, + ]; + + // اضافه کردن zone‌های موجود + for ($i = 1; $i <= $zoneCount; $i++) { + if (isset($zones[$i - 1])) { + $rateData["zone_{$i}"] = $zones[$i - 1]; + } else { + // Zone بیشتر از ۵ = افزایش ۲۰٪ نسبت به قبلی + $prevZone = $rateData["zone_" . ($i - 1)] ?? end($zones); + $rateData["zone_{$i}"] = round($prevZone * 1.2); + } + } + + ShippingRate::firstOrCreate( + [ + 'direction' => 'export', + 'type' => 'PARCEL', + 'weight' => $weight, + ], + $rateData + ); + } + $this->command->info(' ✅ ' . count($baseParcelRates) . ' Parcel rates created'); + + // ─── DOC_NORMAL Export ──────────────────────── + $baseDocNormalRates = [ + 0.5 => [30, 42, 60, 78, 100], + 1.0 => [50, 70, 100, 130, 165], + 2.0 => [80, 110, 160, 205, 260], + 5.0 => [150, 210, 300, 390, 500], + 10.0 => [250, 360, 510, 660, 850], + ]; + + $this->command->info('📄 Creating Export DOC_NORMAL rates...'); + foreach ($baseDocNormalRates as $weight => $zones) { + $rateData = [ + 'direction' => 'export', + 'type' => 'DOC_NORMAL', + 'weight' => $weight, + ]; + + for ($i = 1; $i <= $zoneCount; $i++) { + if (isset($zones[$i - 1])) { + $rateData["zone_{$i}"] = $zones[$i - 1]; + } else { + $prevZone = $rateData["zone_" . ($i - 1)] ?? end($zones); + $rateData["zone_{$i}"] = round($prevZone * 1.2); + } + } + + ShippingRate::firstOrCreate( + [ + 'direction' => 'export', + 'type' => 'DOC_NORMAL', + 'weight' => $weight, + ], + $rateData + ); + } + $this->command->info(' ✅ ' . count($baseDocNormalRates) . ' DOC_NORMAL rates created'); + + // ─── DOC_ECONOMY Export ─────────────────────── + $baseDocEconomyRates = [ + 0.5 => [20, 28, 40, 52, 67], + 1.0 => [35, 48, 68, 88, 112], + 2.0 => [55, 76, 108, 140, 178], + 5.0 => [105, 145, 208, 270, 345], + ]; + + $this->command->info('📃 Creating Export DOC_ECONOMY rates...'); + foreach ($baseDocEconomyRates as $weight => $zones) { + $rateData = [ + 'direction' => 'export', + 'type' => 'DOC_ECONOMY', + 'weight' => $weight, + ]; + + for ($i = 1; $i <= $zoneCount; $i++) { + if (isset($zones[$i - 1])) { + $rateData["zone_{$i}"] = $zones[$i - 1]; + } else { + $prevZone = $rateData["zone_" . ($i - 1)] ?? end($zones); + $rateData["zone_{$i}"] = round($prevZone * 1.2); + } + } + + ShippingRate::firstOrCreate( + [ + 'direction' => 'export', + 'type' => 'DOC_ECONOMY', + 'weight' => $weight, + ], + $rateData + ); + } + $this->command->info(' ✅ ' . count($baseDocEconomyRates) . ' DOC_ECONOMY rates created'); + + // ─── Update Country Zones ───────────────────── + $this->command->info(''); + $this->command->info('🌍 Updating country zones...'); + + // امارات و کشورهای حاشیه خلیج فارس = Zone 1 + $zone1IsoCodes = ['AE', 'QA', 'KW', 'BH', 'OM', 'SA', 'IQ', 'TR']; + Country::whereIn('iso_code', $zone1IsoCodes)->update([ + 'export_zone_parcel' => 1, + 'export_zone_doc' => 1, + 'import_zone_parcel' => 1, + 'import_zone_doc' => 1, + ]); + $this->command->info(' ✅ Zone 1 (Gulf): ' . count($zone1IsoCodes) . ' countries'); + + // خاورمیانه و آسیای مرکزی = Zone 2 + $zone2IsoCodes = ['PK', 'IN', 'AF', 'AZ', 'AM', 'GE', 'KZ', 'UZ', 'TM', 'TJ', 'KG']; + Country::whereIn('iso_code', $zone2IsoCodes)->update([ + 'export_zone_parcel' => 2, + 'export_zone_doc' => 2, + 'import_zone_parcel' => 2, + 'import_zone_doc' => 2, + ]); + $this->command->info(' ✅ Zone 2 (Central Asia): ' . count($zone2IsoCodes) . ' countries'); + + // اروپا = Zone 3 + $europeanCountries = Country::whereIn('iso_code', [ + 'DE', 'FR', 'IT', 'ES', 'GB', 'NL', 'BE', 'AT', 'CH', 'SE', 'NO', 'DK', + 'FI', 'IE', 'PT', 'GR', 'PL', 'CZ', 'HU', 'RO', 'BG', 'HR', 'SI', 'SK', + 'EE', 'LV', 'LT' + ])->update([ + 'export_zone_parcel' => 3, + 'export_zone_doc' => 3, + 'import_zone_parcel' => 3, + 'import_zone_doc' => 3, + ]); + $this->command->info(" ✅ Zone 3 (Europe): {$europeanCountries} countries"); + + // شرق آسیا = Zone 4 + $zone4IsoCodes = ['CN', 'JP', 'KR', 'SG', 'MY', 'TH', 'VN', 'PH', 'ID', 'TW', 'HK']; + Country::whereIn('iso_code', $zone4IsoCodes)->update([ + 'export_zone_parcel' => 4, + 'export_zone_doc' => 4, + 'import_zone_parcel' => 4, + 'import_zone_doc' => 4, + ]); + $this->command->info(' ✅ Zone 4 (East Asia): ' . count($zone4IsoCodes) . ' countries'); + + // آمریکا = Zone 5 + $zone5IsoCodes = ['US', 'CA', 'MX', 'BR', 'AR', 'CL', 'AU', 'NZ']; + Country::whereIn('iso_code', $zone5IsoCodes)->update([ + 'export_zone_parcel' => 5, + 'export_zone_doc' => 5, + 'import_zone_parcel' => 5, + 'import_zone_doc' => 5, + ]); + $this->command->info(' ✅ Zone 5 (Americas/Oceania): ' . count($zone5IsoCodes) . ' countries'); + + // کشورهای باقی‌مانده = Zone 6 (اگر وجود دارد) یا Zone 5 + $defaultZone = $zoneCount >= 6 ? 6 : ($zoneCount >= 5 ? 5 : min($zoneCount, 3)); + $updated = Country::whereNull('export_zone_parcel') + ->orWhere('export_zone_parcel', 0) + ->update([ + 'export_zone_parcel' => $defaultZone, + 'export_zone_doc' => $defaultZone, + 'import_zone_parcel' => $defaultZone, + 'import_zone_doc' => $defaultZone, + ]); + $this->command->info(" ✅ Remaining → Zone {$defaultZone}: {$updated} countries"); + + $this->command->info(''); + $this->command->info('🎉 All shipping rates and zones seeded successfully!'); + } +} \ No newline at end of file diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php index 782ddb2..02ced1a 100644 --- a/04_Laravel/routes/api.php +++ b/04_Laravel/routes/api.php @@ -1,39 +1,68 @@ prefix('v1')->group(function () { Route::get('/track/{awb_no}', [TrackController::class, 'show']); }); -// APIهای احراز هویت شده (نیاز به Sanctum یا Bearer Token) +// ══════════════════════════════════════════════════════════════ +// Auth API (عمومی - برای دریافت توکن) +// ══════════════════════════════════════════════════════════════ +Route::post('/v1/auth/login', [AuthController::class, 'login']); + +Route::middleware(['auth:sanctum'])->post('/v1/auth/logout', [AuthController::class, 'logout']); + +// ══════════════════════════════════════════════════════════════ +// API برای کاربران لاگین‌شده (با Sanctum / Bearer Token) +// ══════════════════════════════════════════════════════════════ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () { - - // Wallet APIها (کاربر عادی) + + // ─── Wallet API (کاربر عادی) ─── Route::get('/wallet/balance', [WalletController::class, 'balance']); Route::get('/wallet/transactions', [WalletController::class, 'transactions']); - - // Payment APIها + + // ─── Payment API ─── Route::post('/payment/redirect', [PaymentController::class, 'redirectToGateway']); Route::get('/payment/check/{transaction}', [PaymentController::class, 'checkStatus']); - - // Admin Wallet APIها + + // ─── Admin Wallet API ─── Route::post('/wallet/admin-adjust', [WalletController::class, 'adminAdjust']); Route::post('/wallet/{wallet}/freeze', [WalletController::class, 'freeze']); Route::post('/wallet/{wallet}/unfreeze', [WalletController::class, 'unfreeze']); Route::get('/wallet/{wallet}/activity-log', [WalletController::class, 'activityLog']); + + // ─── Customer Orders API (جدید) ─── + Route::prefix('customer')->group(function () { + // پروفایل و آمار + Route::get('/profile', [CustomerOrderController::class, 'profile']); + + // لیست کشورها + Route::get('/countries', [CustomerOrderController::class, 'countries']); + + // سفارشات + Route::get('/orders', [CustomerOrderController::class, 'index']); + Route::post('/orders', [CustomerOrderController::class, 'store']); + Route::get('/orders/{shipment}', [CustomerOrderController::class, 'show']); + Route::post('/orders/{shipment}/cancel', [CustomerOrderController::class, 'cancel']); + }); }); + +// ══════════════════════════════════════════════════════════════ // Mock Gateway Routes (عمومی - برای شبیه‌سازی درگاه) +// ══════════════════════════════════════════════════════════════ Route::prefix('v1/payment')->group(function () { Route::get('/mock-gateway', [MockGatewayController::class, 'showGateway']); Route::get('/mock-gateway/success', [MockGatewayController::class, 'simulateSuccess']); @@ -44,7 +73,9 @@ Route::prefix('v1/payment')->group(function () { Route::any('/v1/payment/callback', [PaymentController::class, 'callback']) ->name('payment.callback'); +// ══════════════════════════════════════════════════════════════ // APIهای عمومی (بدون auth) +// ══════════════════════════════════════════════════════════════ Route::post('/v1/calculate', [PricingController::class, 'calculate']); Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']); Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']); \ No newline at end of file diff --git a/04_Laravel/test-api.php b/04_Laravel/test-api.php new file mode 100644 index 0000000..5199d11 --- /dev/null +++ b/04_Laravel/test-api.php @@ -0,0 +1,136 @@ +make(Illuminate\Contracts\Console\Kernel::class); +$kernel->bootstrap(); + +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Hash; +use App\Models\User; + +echo "══════════════════════════════════════════\n"; +echo " 🧪 IFNEX API Test Suite\n"; +echo "══════════════════════════════════════════\n\n"; + +// ─── 0. تنظیم رمز عبور برای تست ───────────────────────────── +$testEmail = 'kazem@vernasoft.group'; +$testPassword = 'Test@12345'; + +$user = User::where('email', $testEmail)->first(); +if (!$user) { + echo "❌ کاربر {$testEmail} پیدا نشد!\n"; + exit(1); +} + +$user->password = Hash::make($testPassword); +$user->save(); +echo "✅ رمز کاربر {$user->name} به '{$testPassword}' تغییر یافت\n\n"; + +// ─── 1. تست Login ────────────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; +echo "تست ۱: دریافت توکن (Login)\n"; +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + +$response = Http::post('http://localhost:8000/api/v1/auth/login', [ + 'email' => $testEmail, + 'password' => $testPassword, + 'token_name' => 'test-api', +]); + +echo "Status: " . $response->status() . "\n"; +$data = $response->json(); +print_r($data); + +if ($response->status() !== 200 || !isset($data['token'])) { + echo "\n❌ Login ناموفق بود! بقیه تست‌ها را نمی‌توان انجام داد.\n"; + exit(1); +} + +$token = $data['token']; +echo "\n✅ Login موفق! توکن دریافت شد.\n\n"; + +// ─── 2. تست Profile ──────────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; +echo "تست ۲: دریافت پروفایل\n"; +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + +$response2 = Http::withToken($token) + ->get('http://localhost:8000/api/v1/customer/profile'); + +echo "Status: " . $response2->status() . "\n"; +print_r($response2->json()); +echo "\n"; + +// ─── 3. تست Countries ────────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; +echo "تست ۳: لیست کشورها\n"; +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + +$response3 = Http::withToken($token) + ->get('http://localhost:8000/api/v1/customer/countries'); + +echo "Status: " . $response3->status() . "\n"; +$countries = $response3->json(); +echo "تعداد کشورها: " . (isset($countries['data']) ? count($countries['data']) : 0) . "\n"; +if (isset($countries['data']) && count($countries['data']) > 0) { + echo "اولین کشور: " . print_r($countries['data'][0], true) . "\n"; +} +echo "\n"; + +// ─── 4. تست ساخت سفارش ──────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; +echo "تست ۴: ثبت سفارش جدید\n"; +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + +$orderData = [ + 'direction' => 'export', + 'type' => 'PARCEL', + 'from_country_id' => 1, + 'to_country_id' => 2, + 'weight' => 2.5, + 'volumetric_weight' => 3, + 'sender_name' => 'Kazem Test', + 'sender_phone' => '09123456789', + 'sender_address' => 'Tehran, Valiasr St.', + 'sender_city' => 'Tehran', + 'receiver_name' => 'Ali Customer', + 'receiver_phone' => '+971501234567', + 'receiver_address' => 'Dubai, Marina', + 'receiver_city' => 'Dubai', +]; + +$response4 = Http::withToken($token) + ->post('http://localhost:8000/api/v1/customer/orders', $orderData); + +echo "Status: " . $response4->status() . "\n"; +$orderResult = $response4->json(); +print_r($orderResult); +echo "\n"; + +// ─── 5. تست لیست سفارشات ─────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; +echo "تست ۵: لیست سفارشات کاربر\n"; +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + +$response5 = Http::withToken($token) + ->get('http://localhost:8000/api/v1/customer/orders'); + +echo "Status: " . $response5->status() . "\n"; +$orders = $response5->json(); +echo "تعداد سفارشات: " . (isset($orders['data']) ? count($orders['data']) : 0) . "\n"; +if (isset($orders['data']) && count($orders['data']) > 0) { + echo "اولین سفارش: AWB = " . ($orders['data'][0]['awb_no'] ?? '—') . "\n"; +} +echo "\n"; + +// ─── جمع‌بندی ─────────────────────────────────────────────── +echo "══════════════════════════════════════════\n"; +echo " 🎯 نتیجه نهایی\n"; +echo "══════════════════════════════════════════\n"; +echo "Login: " . ($response->status() === 200 ? "✅" : "❌") . "\n"; +echo "Profile: " . ($response2->status() === 200 ? "✅" : "❌") . "\n"; +echo "Countries: " . ($response3->status() === 200 ? "✅" : "❌") . "\n"; +echo "Create Order:" . ($response4->status() === 201 ? "✅" : "❌") . "\n"; +echo "List Orders: " . ($response5->status() === 200 ? "✅" : "❌") . "\n"; +echo "══════════════════════════════════════════\n"; \ No newline at end of file