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__)
This commit is contained in:
Kazem Alghasi 2026-08-10 06:09:56 +03:30
parent f61de6f3a5
commit 4ee1d27d44
13 changed files with 2171 additions and 21 deletions

View File

@ -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);
}

View File

@ -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('<option value="">انتخاب کشور مبدأ...</option>');
$to.empty().append('<option value="">انتخاب کشور مقصد...</option>');
$.each(countries, function(i, c) {
var opt = '<option value="' + c.id + '" data-iso="' + c.iso_code + '">' + c.name + '</option>';
$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 += '<div class="ifnex-price-row total"><span>مبلغ قابل پرداخت</span><span>' + formatNum(p.total_fee) + ' ریال</span></div>';
$('#ifnex-price-summary').html(html);
}
function priceRow(label, val) {
return '<div class="ifnex-price-row"><span>' + label + '</span><span>' + formatNum(val) + '</span></div>';
}
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(
'<div class="ifnex-success-box">' +
'<h3>🎉 سفارش شما با موفقیت ثبت شد!</h3>' +
'<p>شماره پیگیری: <code>' + d.awb_no + '</code></p>' +
'<p>مبلغ قابل پرداخت: <strong>' + formatNum(d.total_fee) + ' ریال</strong></p>' +
'<p>وضعیت: ' + d.status.label + '</p>' +
'<a href="' + ifnex_ajax.orders_url + '" class="ifnex-btn ifnex-btn-primary">مشاهده سفارشات من</a>' +
'</div>'
);
} 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();
}
});

View File

@ -189,3 +189,419 @@ function ifnex_transactions_shortcode($atts) {
<?php <?php
return ob_get_clean(); return ob_get_clean();
} }
// ══════════════════════════════════════════════════════════════
// شورت‌کد پروفایل مشتری (جدید)
// ══════════════════════════════════════════════════════════════
add_shortcode('ifnex_user_profile', 'ifnex_user_profile_shortcode');
function ifnex_user_profile_shortcode($atts) {
if (!is_user_logged_in()) {
return '<p class="ifnex-error">برای مشاهده پروفایل، باید وارد شوید.</p>';
}
$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 '<p class="ifnex-error">' . esc_html($profile->get_error_message()) . '</p>';
}
ob_start();
?>
<div class="ifnex-user-profile">
<!-- اطلاعات کاربر -->
<div class="ifnex-profile-header">
<h2>👤 <?php echo esc_html($profile['user']['name']); ?></h2>
<div class="ifnex-profile-info">
<p><strong>ایمیل:</strong> <?php echo esc_html($profile['user']['email']); ?></p>
<p><strong>تلفن:</strong> <?php echo esc_html($profile['user']['phone'] ?? '—'); ?></p>
<p><strong>عضویت از:</strong> <?php echo esc_html($profile['user']['member_since']); ?></p>
</div>
</div>
<?php if ($atts['show_wallet'] === 'yes' && !empty($profile['wallet'])): ?>
<!-- موجودی کیف پول -->
<div class="ifnex-wallet-card">
<h3>💰 کیف پول</h3>
<div class="ifnex-wallet-balance">
<span class="ifnex-balance-amount">
<?php echo number_format($profile['wallet']['balance']); ?> ریال
</span>
<?php if ($profile['wallet']['is_frozen']): ?>
<span class="ifnex-badge ifnex-badge-danger">مسدود</span>
<?php else: ?>
<span class="ifnex-badge ifnex-badge-success">فعال</span>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($atts['show_stats'] === 'yes' && !empty($profile['stats'])): ?>
<!-- آمار سفارشات -->
<div class="ifnex-stats-grid">
<h3>📊 آمار سفارشات</h3>
<div class="ifnex-stats-cards">
<div class="ifnex-stat-card">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['total_orders']); ?></div>
<div class="ifnex-stat-label">کل سفارشات</div>
</div>
<div class="ifnex-stat-card ifnex-stat-warning">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['pending_orders']); ?></div>
<div class="ifnex-stat-label">در انتظار پرداخت</div>
</div>
<div class="ifnex-stat-card ifnex-stat-info">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['active_orders']); ?></div>
<div class="ifnex-stat-label">در حال پردازش</div>
</div>
<div class="ifnex-stat-card ifnex-stat-success">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['delivered_orders']); ?></div>
<div class="ifnex-stat-label">تحویل شده</div>
</div>
</div>
<?php if ($profile['stats']['total_spent'] > 0): ?>
<p class="ifnex-total-spent">
<strong>مجموع خرید:</strong>
<?php echo number_format($profile['stats']['total_spent']); ?> ریال
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- لینک‌های سریع -->
<div class="ifnex-quick-links">
<a href="<?php echo esc_url(home_url('/my-orders/')); ?>" class="ifnex-btn ifnex-btn-primary">
📦 مشاهده سفارشات
</a>
<a href="<?php echo esc_url(home_url('/new-order/')); ?>" class="ifnex-btn ifnex-btn-success">
ثبت سفارش جدید
</a>
</div>
</div>
<?php
return ob_get_clean();
}
// ══════════════════════════════════════════════════════════════
// شورت‌کد لیست سفارشات مشتری
// ══════════════════════════════════════════════════════════════
add_shortcode('ifnex_orders_list', 'ifnex_orders_list_shortcode');
function ifnex_orders_list_shortcode($atts) {
if (!is_user_logged_in()) {
return '<p class="ifnex-error">برای مشاهده سفارشات، باید وارد شوید.</p>';
}
$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 '<p class="ifnex-error">' . esc_html($result->get_error_message()) . '</p>';
}
$orders = $result['data'] ?? [];
$pagination = $result['pagination'] ?? [];
ob_start();
?>
<div class="ifnex-orders-list">
<div class="ifnex-orders-header">
<h2>📦 سفارشات من</h2>
<a href="<?php echo esc_url(home_url('/new-order/')); ?>" class="ifnex-btn ifnex-btn-success">
ثبت سفارش جدید
</a>
</div>
<!-- فیلتر وضعیت -->
<div class="ifnex-filter-bar">
<a href="?status=" class="<?php echo empty($atts['status']) ? 'active' : ''; ?>">همه</a>
<a href="?status=pending_payment" class="<?php echo $atts['status'] === 'pending_payment' ? 'active' : ''; ?>">در انتظار پرداخت</a>
<a href="?status=processed" class="<?php echo $atts['status'] === 'processed' ? 'active' : ''; ?>">پردازش شده</a>
<a href="?status=in_transit" class="<?php echo $atts['status'] === 'in_transit' ? 'active' : ''; ?>">در حال حمل</a>
<a href="?status=delivered" class="<?php echo $atts['status'] === 'delivered' ? 'active' : ''; ?>">تحویل شده</a>
</div>
<?php if (empty($orders)): ?>
<div class="ifnex-empty-state">
<p>📭 هنوز سفارشی ثبت نکرده‌اید.</p>
<a href="<?php echo esc_url(home_url('/new-order/')); ?>" class="ifnex-btn ifnex-btn-primary">
اولین سفارش خود را ثبت کنید
</a>
</div>
<?php else: ?>
<div class="ifnex-orders-grid">
<?php foreach ($orders as $order): ?>
<div class="ifnex-order-card">
<div class="ifnex-order-header">
<div class="ifnex-awb">
<strong>AWB:</strong>
<code><?php echo esc_html($order['awb_no']); ?></code>
</div>
<span class="ifnex-badge ifnex-badge-<?php echo esc_attr($order['status']['color']); ?>">
<?php echo esc_html($order['status']['label']); ?>
</span>
</div>
<div class="ifnex-order-route">
<div class="ifnex-route-from">
<span class="ifnex-label">از:</span>
<span class="ifnex-country">
<?php echo esc_html($order['from_country']['name'] ?? '—'); ?>
</span>
</div>
<div class="ifnex-route-arrow"></div>
<div class="ifnex-route-to">
<span class="ifnex-label">به:</span>
<span class="ifnex-country">
<?php echo esc_html($order['to_country']['name'] ?? '—'); ?>
</span>
</div>
</div>
<div class="ifnex-order-details">
<div class="ifnex-detail-item">
<span class="ifnex-label">وزن:</span>
<span><?php echo esc_html($order['weight']); ?> kg</span>
</div>
<div class="ifnex-detail-item">
<span class="ifnex-label">مبلغ:</span>
<span class="ifnex-price">
<?php echo number_format($order['total_fee']); ?> ریال
</span>
</div>
<div class="ifnex-detail-item">
<span class="ifnex-label">تاریخ:</span>
<span><?php echo esc_html($order['created_at_jalali']); ?></span>
</div>
</div>
<div class="ifnex-order-actions">
<a href="<?php echo esc_url(home_url('/order-detail/' . $order['id'] . '/')); ?>"
class="ifnex-btn ifnex-btn-sm ifnex-btn-info">
👁️ جزئیات
</a>
<?php if ($order['status']['can_cancel']): ?>
<button class="ifnex-btn ifnex-btn-sm ifnex-btn-danger ifnex-cancel-order"
data-order-id="<?php echo esc_attr($order['id']); ?>">
لغو سفارش
</button>
<?php endif; ?>
<?php if (!$order['status']['is_paid']): ?>
<a href="<?php echo esc_url(home_url('/payment/' . $order['id'] . '/')); ?>"
class="ifnex-btn ifnex-btn-sm ifnex-btn-success">
💳 پرداخت
</a>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (!empty($pagination) && $pagination['last_page'] > 1): ?>
<div class="ifnex-pagination">
<?php for ($i = 1; $i <= $pagination['last_page']; $i++): ?>
<a href="?page=<?php echo $i; ?>"
class="<?php echo $i === $pagination['current_page'] ? 'active' : ''; ?>">
<?php echo $i; ?>
</a>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<script>
jQuery(document).ready(function($) {
$('.ifnex-cancel-order').on('click', function() {
if (!confirm('آیا از لغو این سفارش اطمینان دارید؟')) {
return;
}
const orderId = $(this).data('order-id');
const btn = $(this);
btn.prop('disabled', true).text('در حال لغو...');
$.ajax({
url: ifnex_ajax.ajax_url,
method: 'POST',
data: {
action: 'ifnex_cancel_order',
order_id: orderId,
nonce: ifnex_ajax.nonce
},
success: function(response) {
if (response.success) {
alert('سفارش با موفقیت لغو شد.');
location.reload();
} else {
alert('خطا: ' + response.data);
btn.prop('disabled', false).text('❌ لغو سفارش');
}
},
error: function() {
alert('خطا در ارتباط با سرور.');
btn.prop('disabled', false).text('❌ لغو سفارش');
}
});
});
});
</script>
<?php
return ob_get_clean();
}
// ══════════════════════════════════════════════════════════════
// شورت‌کد فرم ثبت سفارش چند مرحله‌ای
// ══════════════════════════════════════════════════════════════
add_shortcode('ifnex_order_form', 'ifnex_order_form_shortcode');
function ifnex_order_form_shortcode($atts) {
if (!is_user_logged_in()) {
ob_start();
?>
<div class="ifnex-login-prompt">
<h3>🔐 ورود لازم است</h3>
<p>برای ثبت سفارش، ابتدا باید وارد حساب کاربری خود شوید.</p>
<a href="<?php echo esc_url(wp_login_url(get_permalink())); ?>" class="ifnex-btn ifnex-btn-primary">ورود به حساب</a>
</div>
<?php
return ob_get_clean();
}
ob_start();
?>
<div class="ifnex-order-form-container" id="ifnex-order-form">
<!-- Progress Steps -->
<div class="ifnex-steps">
<div class="ifnex-step active" data-step="1"><span>۱</span><br>مسیر و بسته</div>
<div class="ifnex-step" data-step="2"><span>۲</span><br>اطلاعات تماس</div>
<div class="ifnex-step" data-step="3"><span>۳</span><br>بررسی قیمت</div>
<div class="ifnex-step" data-step="4"><span>۴</span><br>تأیید نهایی</div>
</div>
<div id="ifnex-form-error" class="ifnex-error-box" style="display:none;"></div>
<!-- Step 1: Route & Package -->
<div class="ifnex-form-step active" data-step="1">
<div class="ifnex-form-card">
<h3>📦 مشخصات مرسوله</h3>
<div class="ifnex-form-grid">
<div class="ifnex-field">
<label>جهت ارسال *</label>
<select id="ifnex-direction">
<option value="export">صادرات (از ایران به خارج)</option>
<option value="import">واردات (از خارج به ایران)</option>
</select>
</div>
<div class="ifnex-field">
<label>نوع سرویس *</label>
<select id="ifnex-type">
<option value="PARCEL">بسته (Parcel)</option>
<option value="DOC_NORMAL">مدارک عادی</option>
<option value="DOC_ECONOMY">مدارک اقتصادی</option>
</select>
</div>
<div class="ifnex-field">
<label>کشور مبدأ *</label>
<select id="ifnex-from-country"></select>
</div>
<div class="ifnex-field">
<label>کشور مقصد *</label>
<select id="ifnex-to-country"></select>
</div>
<div class="ifnex-field">
<label>وزن واقعی (kg) *</label>
<input type="number" id="ifnex-weight" step="0.1" min="0.1" placeholder="مثلاً 2.5">
</div>
<div class="ifnex-field">
<label>وزن حجمی (kg)</label>
<input type="number" id="ifnex-volumetric-weight" step="0.1" min="0" placeholder="اختیاری">
</div>
</div>
<div class="ifnex-form-nav">
<span></span>
<button class="ifnex-btn ifnex-btn-primary ifnex-next-step">ادامه </button>
</div>
</div>
</div>
<!-- Step 2: Sender & Receiver -->
<div class="ifnex-form-step" data-step="2">
<div class="ifnex-form-card">
<h3>👤 اطلاعات فرستنده</h3>
<div class="ifnex-form-grid">
<div class="ifnex-field"><label>نام و نام خانوادگی *</label><input type="text" id="ifnex-sender-name"></div>
<div class="ifnex-field"><label>شماره تماس *</label><input type="tel" id="ifnex-sender-phone"></div>
<div class="ifnex-field"><label>شهر</label><input type="text" id="ifnex-sender-city"></div>
<div class="ifnex-field full"><label>آدرس کامل *</label><textarea id="ifnex-sender-address" rows="2"></textarea></div>
</div>
<h3 style="margin-top:24px;">📮 اطلاعات گیرنده</h3>
<div class="ifnex-form-grid">
<div class="ifnex-field"><label>نام و نام خانوادگی *</label><input type="text" id="ifnex-receiver-name"></div>
<div class="ifnex-field"><label>شماره تماس *</label><input type="tel" id="ifnex-receiver-phone"></div>
<div class="ifnex-field"><label>شهر</label><input type="text" id="ifnex-receiver-city"></div>
<div class="ifnex-field full"><label>آدرس کامل *</label><textarea id="ifnex-receiver-address" rows="2"></textarea></div>
</div>
<div class="ifnex-form-nav">
<button class="ifnex-btn ifnex-prev-step"> قبلی</button>
<button class="ifnex-btn ifnex-btn-primary ifnex-next-step">ادامه </button>
</div>
</div>
</div>
<!-- Step 3: Review & Discount -->
<div class="ifnex-form-step" data-step="3">
<div class="ifnex-form-card">
<h3>🏷️ کد تخفیف (اختیاری)</h3>
<div class="ifnex-form-grid">
<div class="ifnex-field full"><label>کد تخفیف</label><input type="text" id="ifnex-discount-code" placeholder="مثلاً WELCOME10"></div>
</div>
<div id="ifnex-calc-error" class="ifnex-error-box" style="display:none;"></div>
<div id="ifnex-calc-loading" style="display:none; text-align:center; padding:20px;">
در حال محاسبه قیمت...
</div>
<div class="ifnex-form-nav">
<button class="ifnex-btn ifnex-prev-step"> قبلی</button>
<button class="ifnex-btn ifnex-btn-success ifnex-next-step">💰 محاسبه قیمت</button>
</div>
</div>
</div>
<!-- Step 4: Confirm -->
<div class="ifnex-form-step" data-step="4">
<div class="ifnex-form-card">
<h3> تأیید نهایی سفارش</h3>
<div class="ifnex-price-summary" id="ifnex-price-summary"></div>
<div id="ifnex-submit-error" class="ifnex-error-box" style="display:none; margin-top:16px;"></div>
<div class="ifnex-form-nav">
<button class="ifnex-btn ifnex-prev-step"> قبلی</button>
<button class="ifnex-btn ifnex-btn-success" id="ifnex-submit-order"> تأیید و ثبت سفارش</button>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}

View File

@ -47,8 +47,10 @@ class IFNEX_User_Bridge {
* درخواست توکن از لاراول * درخواست توکن از لاراول
*/ */
private function request_token($email, $password) { private function request_token($email, $password) {
$url = $this->api_url . '/sanctum/token'; //$url = $this->api_url . '/sanctum/token';
$url = $this->api_url . '/auth/login';
$args = array( $args = array(
'headers' => array( 'headers' => array(
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
@ -218,4 +220,245 @@ class IFNEX_User_Bridge {
return $data; 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/'),
));
}
}

View File

@ -4,11 +4,78 @@ namespace App\Enums;
enum ShipmentStatus: string enum ShipmentStatus: string
{ {
case Processed = 'processed'; // ─── وضعیت‌های مشتری (قبل از پرداخت) ───
case PickedUp = 'picked_up'; case PendingPayment = 'pending_payment'; // در انتظار پرداخت
case InTransit = 'in_transit'; case Cancelled = 'cancelled'; // لغو شده توسط مشتری
case OutForDelivery = 'out_for_delivery';
case Failed = 'failed'; // ─── وضعیت‌های عملیاتی (بعد از پرداخت) ───
case Delivered = 'delivered'; case Processed = 'processed'; // پردازش شده (ادمین تأیید کرده)
case Returned = 'returned'; 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,
]);
}
}

View File

@ -45,6 +45,21 @@ class ShipmentResource extends Resource
'PARCEL' => 'Parcel', 'PARCEL' => 'Parcel',
]) ])
->required(), ->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') Forms\Components\Select::make('status')
->options([ ->options([
'processed' => 'Processed', 'processed' => 'Processed',
@ -301,6 +316,8 @@ class ShipmentResource extends Resource
->filters([ ->filters([
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options([ ->options([
'pending_payment' => 'در انتظار پرداخت',
'cancelled' => 'لغو شده',
'processed' => 'Processed', 'processed' => 'Processed',
'picked_up' => 'Picked Up', 'picked_up' => 'Picked Up',
'in_transit' => 'In Transit', 'in_transit' => 'In Transit',

View File

@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
/**
* ورود و دریافت توکن Sanctum
* POST /api/v1/auth/login
*/
public function login(Request $request)
{
$validated = $request->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' => 'خروج با موفقیت انجام شد.',
]);
}
}

View File

@ -0,0 +1,492 @@
<?php
namespace App\Http\Controllers\Api\Customer;
use App\Http\Controllers\Controller;
use App\Models\Shipment;
use App\Models\ShipmentItem;
use App\Models\Country;
use App\Enums\ShipmentStatus;
use App\Enums\ShipmentDirection;
use App\Enums\ShipmentType;
use App\Services\PriceCalculatorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CustomerOrderController extends Controller
{
public function __construct(
protected PriceCalculatorService $calculator
) {}
/**
* لیست سفارشات کاربر
* GET /api/v1/customer/orders
*/
public function index(Request $request): JsonResponse
{
$validated = $request->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');
}
}
}

View File

@ -6,3 +6,8 @@ abstract class Controller
{ {
// //
} }
class CustomerOrderController
{
// ...
}

View File

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

View File

@ -0,0 +1,214 @@
<?php
namespace Database\Seeders;
use App\Models\Country;
use App\Models\ShippingRate;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
class SampleShippingRatesSeeder extends Seeder
{
public function run(): void
{
// بررسی zoneهای موجود در جدول
$allColumns = Schema::getColumnListing('shipping_rates');
$zoneColumns = array_filter($allColumns, fn($col) => 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!');
}
}

View File

@ -1,39 +1,68 @@
<?php <?php
use App\Http\Controllers\Api\Customer\CustomerOrderController;
use App\Http\Controllers\Api\DiscountCodeController; use App\Http\Controllers\Api\DiscountCodeController;
use App\Http\Controllers\Api\MockGatewayController;
use App\Http\Controllers\Api\PaymentController; use App\Http\Controllers\Api\PaymentController;
use App\Http\Controllers\Api\PricingController; use App\Http\Controllers\Api\PricingController;
use App\Http\Controllers\Api\TrackController; use App\Http\Controllers\Api\TrackController;
use App\Http\Controllers\Api\WalletController; use App\Http\Controllers\Api\WalletController;
use Illuminate\Support\Facades\Route;
use App\Http\Middleware\ApiKeyMiddleware; use App\Http\Middleware\ApiKeyMiddleware;
use App\Http\Controllers\Api\MockGatewayController; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;
// ══════════════════════════════════════════════════════════════
// API عمومی با API Key
// APIهای عمومی با API Key // ══════════════════════════════════════════════════════════════
Route::middleware([ApiKeyMiddleware::class])->prefix('v1')->group(function () { Route::middleware([ApiKeyMiddleware::class])->prefix('v1')->group(function () {
Route::get('/track/{awb_no}', [TrackController::class, 'show']); 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 () { Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// Wallet APIها (کاربر عادی) // ─── Wallet API (کاربر عادی) ───
Route::get('/wallet/balance', [WalletController::class, 'balance']); Route::get('/wallet/balance', [WalletController::class, 'balance']);
Route::get('/wallet/transactions', [WalletController::class, 'transactions']); Route::get('/wallet/transactions', [WalletController::class, 'transactions']);
// Payment APIها // ─── Payment API ───
Route::post('/payment/redirect', [PaymentController::class, 'redirectToGateway']); Route::post('/payment/redirect', [PaymentController::class, 'redirectToGateway']);
Route::get('/payment/check/{transaction}', [PaymentController::class, 'checkStatus']); 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/admin-adjust', [WalletController::class, 'adminAdjust']);
Route::post('/wallet/{wallet}/freeze', [WalletController::class, 'freeze']); Route::post('/wallet/{wallet}/freeze', [WalletController::class, 'freeze']);
Route::post('/wallet/{wallet}/unfreeze', [WalletController::class, 'unfreeze']); Route::post('/wallet/{wallet}/unfreeze', [WalletController::class, 'unfreeze']);
Route::get('/wallet/{wallet}/activity-log', [WalletController::class, 'activityLog']); 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 (عمومی - برای شبیه‌سازی درگاه) // Mock Gateway Routes (عمومی - برای شبیه‌سازی درگاه)
// ══════════════════════════════════════════════════════════════
Route::prefix('v1/payment')->group(function () { Route::prefix('v1/payment')->group(function () {
Route::get('/mock-gateway', [MockGatewayController::class, 'showGateway']); Route::get('/mock-gateway', [MockGatewayController::class, 'showGateway']);
Route::get('/mock-gateway/success', [MockGatewayController::class, 'simulateSuccess']); 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']) Route::any('/v1/payment/callback', [PaymentController::class, 'callback'])
->name('payment.callback'); ->name('payment.callback');
// ══════════════════════════════════════════════════════════════
// APIهای عمومی (بدون auth) // APIهای عمومی (بدون auth)
// ══════════════════════════════════════════════════════════════
Route::post('/v1/calculate', [PricingController::class, 'calculate']); Route::post('/v1/calculate', [PricingController::class, 'calculate']);
Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']); Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']);
Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']); Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']);

136
04_Laravel/test-api.php Normal file
View File

@ -0,0 +1,136 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->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";