- Add shipment_packages migration and ShipmentPackage model
- Add packages() relation to Shipment model - Update CustomerOrderController to accept packages[] array - Auto-calculate volumetric weight from dimensions (L*W*H/5000) - Redesign order form Step 1 with Multi-Package UI - Add package repeater (add/remove packages) - Real-time summary of total weights - Both real weight and dimensions are required - Dimensions normalized to * format (5*6*9) Phase 3.5.1 — Multi-Package complete"
This commit is contained in:
parent
f12dff5338
commit
1c8fb2c9d9
@ -4,6 +4,7 @@ jQuery(document).ready(function($) {
|
||||
var currentStep = 1;
|
||||
var countries = [];
|
||||
var priceData = null;
|
||||
var packages = []; // آرایه بستهها
|
||||
|
||||
// ─── Load Countries ───
|
||||
function loadCountries() {
|
||||
@ -42,7 +43,6 @@ jQuery(document).ready(function($) {
|
||||
var $country = $(countrySelectId + ' option:selected');
|
||||
var code = $country.data('calling-code') || '';
|
||||
var $phone = $(phoneInputId);
|
||||
// حذف کد قدیمی اگر وجود داره
|
||||
var currentVal = $phone.val().replace(/^\+\d+\s?/, '').trim();
|
||||
if (code) {
|
||||
$phone.val(code + ' ' + currentVal).css('border-color', '');
|
||||
@ -69,7 +69,7 @@ jQuery(document).ready(function($) {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── English-only validation for all text fields ───
|
||||
// ─── English-only validation for text fields ───
|
||||
function isPersianText(text) {
|
||||
var cleaned = text.replace(/[\s0-9\-.,،؛:()\/#]/g, '');
|
||||
return /[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]/.test(cleaned);
|
||||
@ -90,7 +90,6 @@ jQuery(document).ready(function($) {
|
||||
});
|
||||
}
|
||||
|
||||
// همه فیلدهای متنی باید انگلیسی باشن
|
||||
setupEnglishField('#ifnex-sender-name', '#ifnex-sender-name-warning');
|
||||
setupEnglishField('#ifnex-sender-city', '#ifnex-sender-city-warning');
|
||||
setupEnglishField('#ifnex-sender-address', '#ifnex-sender-address-warning');
|
||||
@ -98,6 +97,185 @@ jQuery(document).ready(function($) {
|
||||
setupEnglishField('#ifnex-receiver-city', '#ifnex-receiver-city-warning');
|
||||
setupEnglishField('#ifnex-receiver-address', '#ifnex-receiver-address-warning');
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ─── Multi-Package Management (جدولی) ───
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// محاسبه وزن حجمی از ابعاد (با پشتیبانی از * و x و ,)
|
||||
function calculateVolumetricWeight(dimensions) {
|
||||
if (!dimensions) return 0;
|
||||
// نرمالسازی: تبدیل x و , به *
|
||||
var normalized = dimensions.replace(/[xX,]/g, '*');
|
||||
var parts = normalized.split('*');
|
||||
if (parts.length !== 3) return 0;
|
||||
var length = parseFloat(parts[0].trim());
|
||||
var width = parseFloat(parts[1].trim());
|
||||
var height = parseFloat(parts[2].trim());
|
||||
if (isNaN(length) || isNaN(width) || isNaN(height)) return 0;
|
||||
if (length <= 0 || width <= 0 || height <= 0) return 0;
|
||||
return Math.round((length * width * height) / 5000 * 1000) / 1000;
|
||||
}
|
||||
|
||||
// نرمالسازی ابعاد به فرمت * (مثلاً 5*6*9)
|
||||
function normalizeDimensions(dimensions) {
|
||||
if (!dimensions) return '';
|
||||
return dimensions.replace(/[xX,]/g, '*').trim();
|
||||
}
|
||||
|
||||
// افزودن یه بسته جدید
|
||||
function addPackage() {
|
||||
var pkgNumber = packages.length + 1;
|
||||
var pkg = {
|
||||
id: 'pkg_' + Date.now() + '_' + pkgNumber,
|
||||
number: pkgNumber,
|
||||
weight: '',
|
||||
dimensions: '',
|
||||
volumetricWeight: 0,
|
||||
description: ''
|
||||
};
|
||||
packages.push(pkg);
|
||||
renderPackage(pkg);
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
// حذف یه بسته
|
||||
function removePackage(pkgId) {
|
||||
if (packages.length <= 1) {
|
||||
alert('حداقل باید یک بسته وجود داشته باشد.');
|
||||
return;
|
||||
}
|
||||
packages = packages.filter(function(p) { return p.id !== pkgId; });
|
||||
// بروزرسانی شماره بستهها
|
||||
packages.forEach(function(p, i) {
|
||||
p.number = i + 1;
|
||||
});
|
||||
renderAllPackages();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
// رندر یه بسته بهصورت ساده (با فیلدهای جداگانه مثل بقیه فرم)
|
||||
function renderPackage(pkg) {
|
||||
var html = '<div class="ifnex-package-item" data-pkg-id="' + pkg.id + '" style="border: 1px solid #ddd; padding: 16px; margin-bottom: 12px; border-radius: 8px; background: #fafafa; position: relative;">';
|
||||
html += '<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">';
|
||||
html += '<strong style="color: #f37021; font-size: 14px;">📦 بسته #' + pkg.number + '</strong>';
|
||||
html += '<button type="button" class="ifnex-btn ifnex-btn-sm ifnex-btn-danger ifnex-remove-package" data-pkg-id="' + pkg.id + '" title="حذف بسته" style="padding: 4px 8px;">❌ حذف</button>';
|
||||
html += '</div>';
|
||||
html += '<div class="ifnex-form-grid">';
|
||||
|
||||
// وزن واقعی
|
||||
html += '<div class="ifnex-field">';
|
||||
html += '<label>وزن واقعی (kg) *</label>';
|
||||
html += '<input type="number" class="ifnex-pkg-weight" step="0.1" min="0.1" data-pkg-id="' + pkg.id + '" placeholder="مثلاً 2.5" value="' + (pkg.weight || '') + '" required>';
|
||||
html += '</div>';
|
||||
|
||||
// ابعاد
|
||||
html += '<div class="ifnex-field">';
|
||||
html += '<label>ابعاد (طول×عرض×ارتفاع cm) *</label>';
|
||||
html += '<input type="text" class="ifnex-pkg-dimensions" data-pkg-id="' + pkg.id + '" placeholder="مثلاً 5*6*9" value="' + (pkg.dimensions || '') + '" style="direction: ltr;" required>';
|
||||
html += '</div>';
|
||||
|
||||
// وزن حجمی (محاسبه خودکار - فقط نمایش)
|
||||
html += '<div class="ifnex-field">';
|
||||
html += '<label>وزن حجمی (kg)</label>';
|
||||
html += '<input type="text" class="ifnex-pkg-volumetric-display" data-pkg-id="' + pkg.id + '" value="0" readonly style="background: #f0f9ff; color: #0369a1; font-weight: bold;">';
|
||||
html += '</div>';
|
||||
|
||||
// توضیحات
|
||||
html += '<div class="ifnex-field full">';
|
||||
html += '<label>توضیحات بسته (اختیاری)</label>';
|
||||
html += '<input type="text" class="ifnex-pkg-description" data-pkg-id="' + pkg.id + '" placeholder="مثلاً کتاب، لباس، اسباببازی..." value="' + (pkg.description || '') + '">';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
$('#ifnex-packages-container').append(html);
|
||||
}
|
||||
|
||||
// رندر همه بستهها
|
||||
function renderAllPackages() {
|
||||
$('#ifnex-packages-container').empty();
|
||||
packages.forEach(function(pkg) {
|
||||
renderPackage(pkg);
|
||||
});
|
||||
}
|
||||
|
||||
// بروزرسانی خلاصه وزنها
|
||||
function updateSummary() {
|
||||
var totalWeight = 0;
|
||||
var totalVolumetric = 0;
|
||||
var hasWeight = false;
|
||||
|
||||
packages.forEach(function(pkg) {
|
||||
if (pkg.weight && pkg.weight > 0) {
|
||||
totalWeight += parseFloat(pkg.weight);
|
||||
hasWeight = true;
|
||||
}
|
||||
totalVolumetric += pkg.volumetricWeight || 0;
|
||||
});
|
||||
|
||||
var chargeable = Math.max(totalWeight, totalVolumetric);
|
||||
|
||||
$('#ifnex-package-count').text(packages.length);
|
||||
$('#ifnex-total-weight').text(totalWeight.toFixed(2));
|
||||
$('#ifnex-total-volumetric').text(totalVolumetric.toFixed(2));
|
||||
$('#ifnex-chargeable-weight').text(chargeable.toFixed(2));
|
||||
|
||||
if (packages.length > 0) {
|
||||
$('#ifnex-packages-summary').show();
|
||||
} else {
|
||||
$('#ifnex-packages-summary').hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Event: افزودن بسته
|
||||
$('#ifnex-add-package').on('click', function() {
|
||||
addPackage();
|
||||
});
|
||||
|
||||
// Event: تغییر وزن بسته
|
||||
$(document).on('input', '.ifnex-pkg-weight', function() {
|
||||
var pkgId = $(this).data('pkg-id');
|
||||
var weight = $(this).val();
|
||||
var pkg = packages.find(function(p) { return p.id === pkgId; });
|
||||
if (pkg) {
|
||||
pkg.weight = weight;
|
||||
updateSummary();
|
||||
}
|
||||
});
|
||||
|
||||
// Event: تغییر ابعاد بسته (محاسبه وزن حجمی + نرمالسازی)
|
||||
$(document).on('input', '.ifnex-pkg-dimensions', function() {
|
||||
var pkgId = $(this).data('pkg-id');
|
||||
var rawDimensions = $(this).val();
|
||||
var normalized = normalizeDimensions(rawDimensions);
|
||||
var volWeight = calculateVolumetricWeight(normalized);
|
||||
var pkg = packages.find(function(p) { return p.id === pkgId; });
|
||||
if (pkg) {
|
||||
pkg.dimensions = normalized;
|
||||
pkg.volumetricWeight = volWeight;
|
||||
// آپدیت کردن فیلد وزن حجمی (readonly)
|
||||
$('.ifnex-pkg-volumetric-display[data-pkg-id="' + pkgId + '"]').val(volWeight.toFixed(3));
|
||||
updateSummary();
|
||||
}
|
||||
});
|
||||
|
||||
// Event: تغییر توضیحات بسته
|
||||
$(document).on('input', '.ifnex-pkg-description', function() {
|
||||
var pkgId = $(this).data('pkg-id');
|
||||
var desc = $(this).val();
|
||||
var pkg = packages.find(function(p) { return p.id === pkgId; });
|
||||
if (pkg) {
|
||||
pkg.description = desc;
|
||||
}
|
||||
});
|
||||
|
||||
// Event: حذف بسته
|
||||
$(document).on('click', '.ifnex-remove-package', function() {
|
||||
var pkgId = $(this).data('pkg-id');
|
||||
removePackage(pkgId);
|
||||
});
|
||||
|
||||
// ─── Step Navigation ───
|
||||
function goToStep(step) {
|
||||
currentStep = step;
|
||||
@ -125,11 +303,39 @@ jQuery(document).ready(function($) {
|
||||
|
||||
function validateStep1() {
|
||||
var ok = true;
|
||||
['#ifnex-direction', '#ifnex-type', '#ifnex-from-country', '#ifnex-to-country', '#ifnex-weight'].forEach(function(sel) {
|
||||
['#ifnex-direction', '#ifnex-type', '#ifnex-from-country', '#ifnex-to-country'].forEach(function(sel) {
|
||||
if (!$(sel).val()) { $(sel).css('border-color', '#ef4444'); ok = false; }
|
||||
else { $(sel).css('border-color', ''); }
|
||||
});
|
||||
if (!ok) showFormError('لطفاً همه فیلدهای مرحله ۱ را تکمیل کنید.');
|
||||
|
||||
// بررسی بستهها
|
||||
if (packages.length === 0) {
|
||||
showFormError('حداقل باید یک بسته اضافه کنید.');
|
||||
ok = false;
|
||||
} else {
|
||||
var allValid = true;
|
||||
packages.forEach(function(pkg) {
|
||||
var hasWeight = pkg.weight && parseFloat(pkg.weight) > 0;
|
||||
var hasVolumetric = pkg.volumetricWeight && pkg.volumetricWeight > 0;
|
||||
if (!hasWeight) {
|
||||
$('.ifnex-pkg-weight[data-pkg-id="' + pkg.id + '"]').css('border-color', '#ef4444');
|
||||
allValid = false;
|
||||
} else {
|
||||
$('.ifnex-pkg-weight[data-pkg-id="' + pkg.id + '"]').css('border-color', '');
|
||||
}
|
||||
if (!hasVolumetric) {
|
||||
$('.ifnex-pkg-dimensions[data-pkg-id="' + pkg.id + '"]').css('border-color', '#ef4444');
|
||||
allValid = false;
|
||||
} else {
|
||||
$('.ifnex-pkg-dimensions[data-pkg-id="' + pkg.id + '"]').css('border-color', '');
|
||||
}
|
||||
});
|
||||
if (!allValid) {
|
||||
showFormError('برای هر بسته، وزن واقعی و ابعاد را وارد کنید.');
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
@ -175,6 +381,27 @@ jQuery(document).ready(function($) {
|
||||
function buildOrderData() {
|
||||
var $fromOpt = $('#ifnex-from-country option:selected');
|
||||
var $toOpt = $('#ifnex-to-country option:selected');
|
||||
|
||||
// محاسبه وزنهای کل
|
||||
var totalWeight = 0;
|
||||
var totalVolumetric = 0;
|
||||
packages.forEach(function(pkg) {
|
||||
if (pkg.weight && parseFloat(pkg.weight) > 0) {
|
||||
totalWeight += parseFloat(pkg.weight);
|
||||
}
|
||||
totalVolumetric += pkg.volumetricWeight || 0;
|
||||
});
|
||||
var chargeableWeight = Math.max(totalWeight, totalVolumetric);
|
||||
|
||||
// ساخت آرایه بستهها برای ارسال به API
|
||||
var packagesArray = packages.map(function(pkg) {
|
||||
return {
|
||||
weight: parseFloat(pkg.weight) || 0,
|
||||
dimensions: pkg.dimensions || '',
|
||||
description: pkg.description || ''
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
direction: $('#ifnex-direction').val(),
|
||||
type: $('#ifnex-type').val(),
|
||||
@ -183,8 +410,10 @@ jQuery(document).ready(function($) {
|
||||
from_country_iso: $fromOpt.data('iso'),
|
||||
to_country_iso: $toOpt.data('iso'),
|
||||
country_iso: $('#ifnex-direction').val() === 'export' ? $toOpt.data('iso') : $fromOpt.data('iso'),
|
||||
weight: parseFloat($('#ifnex-weight').val()) || 0,
|
||||
volumetric_weight: parseFloat($('#ifnex-volumetric-weight').val()) || 0,
|
||||
weight: totalWeight,
|
||||
volumetric_weight: totalVolumetric,
|
||||
chargeable_weight: chargeableWeight,
|
||||
packages: packagesArray,
|
||||
discount_code: $('#ifnex-discount-code').val() || '',
|
||||
sender_name: $('#ifnex-sender-name').val(),
|
||||
sender_phone: $('#ifnex-sender-phone').val(),
|
||||
@ -199,6 +428,11 @@ jQuery(document).ready(function($) {
|
||||
|
||||
function renderPriceSummary(data) {
|
||||
var html = '<div class="ifnex-price-breakdown">';
|
||||
html += '<div class="ifnex-price-row"><span>تعداد بستهها:</span><strong>' + packages.length + '</strong></div>';
|
||||
html += '<div class="ifnex-price-row"><span>مجموع وزن واقعی:</span><strong>' + numberFormat(data.weight || 0) + ' kg</strong></div>';
|
||||
html += '<div class="ifnex-price-row"><span>مجموع وزن حجمی:</span><strong>' + numberFormat(data.volumetric_weight || 0) + ' kg</strong></div>';
|
||||
html += '<div class="ifnex-price-row"><span>وزن قابل محاسبه:</span><strong>' + numberFormat(data.chargeable_weight || 0) + ' kg</strong></div>';
|
||||
html += '<hr style="margin: 10px 0;">';
|
||||
html += '<div class="ifnex-price-row"><span>قیمت پایه:</span><strong>' + (data.base_price || '0') + ' درهم</strong></div>';
|
||||
html += '<div class="ifnex-price-row"><span>ناخالص:</span><strong>' + (data.net_dirham || '0') + ' درهم</strong></div>';
|
||||
html += '<div class="ifnex-price-row"><span>ریال:</span><strong>' + numberFormat(data.net_rial) + ' ریال</strong></div>';
|
||||
@ -213,7 +447,7 @@ jQuery(document).ready(function($) {
|
||||
}
|
||||
|
||||
function numberFormat(n) {
|
||||
return new Intl.NumberFormat('fa-IR').format(n || 0);
|
||||
return new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 2 }).format(n || 0);
|
||||
}
|
||||
|
||||
function showFormError(msg) {
|
||||
@ -253,5 +487,7 @@ jQuery(document).ready(function($) {
|
||||
// ─── Init ───
|
||||
if ($('#ifnex-order-form').length) {
|
||||
loadCountries();
|
||||
// افزودن بسته اول بهصورت پیشفرض
|
||||
addPackage();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -126,114 +126,6 @@ function ifnex_wallet_balance_shortcode($atts) {
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// شورتکد فرم شارژ کیف پول (جدید)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
add_shortcode('ifnex_wallet_charge', 'ifnex_wallet_charge_shortcode');
|
||||
|
||||
function ifnex_wallet_charge_shortcode($atts) {
|
||||
if (!is_user_logged_in()) {
|
||||
return '<p class="ifnex-error">برای شارژ کیف پول، باید وارد شوید.</p>';
|
||||
}
|
||||
|
||||
$bridge = new IFNEX_User_Bridge();
|
||||
$balance = $bridge->get_wallet_balance(get_current_user_id());
|
||||
$current_balance = is_wp_error($balance) ? 0 : ($balance['balance'] ?? 0);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="ifnex-wallet-charge-page">
|
||||
<div class="ifnex-wallet-balance">
|
||||
<div class="ifnex-balance-main">
|
||||
<span class="ifnex-balance-label">موجودی فعلی:</span>
|
||||
<span class="ifnex-balance-amount"><?php echo number_format($current_balance); ?> ریال</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ifnex-charge-card">
|
||||
<h3>💳 شارژ کیف پول</h3>
|
||||
<p class="ifnex-charge-desc">برای شارژ کیف پول، مبلغ مورد نظر را وارد کنید. شما به درگاه پرداخت زرینپال منتقل خواهید شد.</p>
|
||||
|
||||
<div class="ifnex-charge-amounts">
|
||||
<button type="button" class="ifnex-amount-btn" data-amount="5000000">۵ میلیون</button>
|
||||
<button type="button" class="ifnex-amount-btn" data-amount="10000000">۱۰ میلیون</button>
|
||||
<button type="button" class="ifnex-amount-btn" data-amount="20000000">۲۰ میلیون</button>
|
||||
<button type="button" class="ifnex-amount-btn" data-amount="50000000">۵۰ میلیون</button>
|
||||
<button type="button" class="ifnex-amount-btn" data-amount="100000000">۱۰۰ میلیون</button>
|
||||
</div>
|
||||
|
||||
<div class="ifnex-form-grid">
|
||||
<div class="ifnex-field">
|
||||
<label>مبلغ شارژ (ریال) *</label>
|
||||
<input type="number" id="ifnex-charge-amount" min="10000" step="10000" placeholder="مثلاً 10000000">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ifnex-charge-error" class="ifnex-error-box" style="display:none;"></div>
|
||||
|
||||
<button type="button" id="ifnex-charge-submit" class="ifnex-btn ifnex-btn-success ifnex-btn-lg">
|
||||
💳 انتقال به درگاه پرداخت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
jQuery(document).ready(function($) {
|
||||
// انتخاب مبالغ آماده
|
||||
$('.ifnex-amount-btn').on('click', function() {
|
||||
$('#ifnex-charge-amount').val($(this).data('amount'));
|
||||
$('.ifnex-amount-btn').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
});
|
||||
|
||||
$('#ifnex-charge-submit').on('click', function() {
|
||||
var btn = $(this);
|
||||
var amount = parseInt($('#ifnex-charge-amount').val());
|
||||
|
||||
if (!amount || amount < 10000) {
|
||||
$('#ifnex-charge-error').text('مبلغ نامعتبر است. حداقل ۱۰,۰۰۰ ریال').show();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.prop('disabled', true).text('در حال انتقال به درگاه...');
|
||||
$('#ifnex-charge-error').hide();
|
||||
|
||||
$.ajax({
|
||||
url: ifnex_ajax.ajax_url,
|
||||
method: 'POST',
|
||||
data: {
|
||||
action: 'ifnex_wallet_charge',
|
||||
amount: amount,
|
||||
nonce: ifnex_ajax.nonce
|
||||
},
|
||||
success: function(res) {
|
||||
if (res.success && res.data.payment_url) {
|
||||
window.location.href = res.data.payment_url;
|
||||
} else {
|
||||
btn.prop('disabled', false).text('💳 انتقال به درگاه پرداخت');
|
||||
var msg = res.data;
|
||||
if (typeof msg === 'object') {
|
||||
msg = msg.message || JSON.stringify(msg);
|
||||
}
|
||||
$('#ifnex-charge-error').text('خطا: ' + (msg || 'نامشخص')).show();
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
btn.prop('disabled', false).text('💳 انتقال به درگاه پرداخت');
|
||||
var errObj;
|
||||
try { errObj = JSON.parse(xhr.responseText); } catch (e) { errObj = null; }
|
||||
var msg = errObj && (errObj.message || errObj.error) ? (errObj.message || errObj.error) : 'خطای سرور (' + xhr.status + ')';
|
||||
$('#ifnex-charge-error').text('خطا: ' + msg).show();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
|
||||
// شورتکد لیست تراکنشها
|
||||
add_shortcode('ifnex_transactions', 'ifnex_transactions_shortcode');
|
||||
@ -639,13 +531,35 @@ function ifnex_order_form_shortcode($atts) {
|
||||
<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="اختیاری">
|
||||
<!-- ─── بخش بستهها (Multi-Package) ─── -->
|
||||
<h3 style="margin-top: 24px;">📦 بستههای مرسوله</h3>
|
||||
<p style="font-size: 12px; color: #666; margin-bottom: 12px;">
|
||||
اگر چند بسته دارید، برای هرکدوم اطلاعات جداگانه وارد کنید. وزن واقعی و ابعاد هر دو اجباری هستند (وزن حجمی خودکار محاسبه میشود).
|
||||
</p>
|
||||
|
||||
<div id="ifnex-packages-container">
|
||||
<!-- بستهها اینجا اضافه میشن -->
|
||||
</div>
|
||||
|
||||
<button type="button" id="ifnex-add-package" class="ifnex-btn ifnex-btn-info" style="margin-top: 10px;">
|
||||
➕ افزودن بسته دیگر
|
||||
</button>
|
||||
|
||||
<!-- خلاصه وزنها -->
|
||||
<div class="ifnex-packages-summary" id="ifnex-packages-summary" style="display:none; margin-top: 16px; padding: 12px; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;">
|
||||
<div style="display: flex; justify-content: space-between; gap: 20px;">
|
||||
<div>
|
||||
<strong>تعداد بستهها:</strong> <span id="ifnex-package-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>مجموع وزن واقعی:</strong> <span id="ifnex-total-weight">0</span> kg
|
||||
</div>
|
||||
<div>
|
||||
<strong>مجموع وزن حجمی:</strong> <span id="ifnex-total-volumetric">0</span> kg
|
||||
</div>
|
||||
<div>
|
||||
<strong>وزن قابل محاسبه:</strong> <span id="ifnex-chargeable-weight">0</span> kg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ifnex-form-nav">
|
||||
@ -655,67 +569,23 @@ function ifnex_order_form_shortcode($atts) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Sender & Receiver -->
|
||||
<!-- 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 class="ifnex-address-warning" id="ifnex-sender-name-warning" style="display:none;">
|
||||
⚠️ نام را به زبان انگلیسی وارد کنید. در صورت عدم توانایی، با پشتیبانی تماس بگیرید.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ifnex-field">
|
||||
<label>شماره تماس *</label>
|
||||
<input type="tel" id="ifnex-sender-phone" placeholder="مثال: +98 9123456789">
|
||||
</div>
|
||||
<div class="ifnex-field">
|
||||
<label>شهر *</label>
|
||||
<input type="text" id="ifnex-sender-city">
|
||||
<div class="ifnex-address-warning" id="ifnex-sender-city-warning" style="display:none;">
|
||||
⚠️ نام شهر را به زبان انگلیسی وارد کنید. در صورت عدم توانایی، با پشتیبانی تماس بگیرید.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ifnex-field full">
|
||||
<label>آدرس کامل *</label>
|
||||
<textarea id="ifnex-sender-address" rows="2"></textarea>
|
||||
<div class="ifnex-address-warning" id="ifnex-sender-address-warning" style="display:none;">
|
||||
⚠️ آدرس را به زبان انگلیسی وارد کنید (برای حمل بینالمللی). در صورت عدم توانایی، با پشتیبانی تماس بگیرید.
|
||||
</div>
|
||||
</div>
|
||||
<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 class="ifnex-address-warning" id="ifnex-receiver-name-warning" style="display:none;">
|
||||
⚠️ نام را به زبان انگلیسی وارد کنید. در صورت عدم توانایی، با پشتیبانی تماس بگیرید.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ifnex-field">
|
||||
<label>شماره تماس *</label>
|
||||
<input type="tel" id="ifnex-receiver-phone" placeholder="مثال: +971 501234567">
|
||||
</div>
|
||||
<div class="ifnex-field">
|
||||
<label>شهر *</label>
|
||||
<input type="text" id="ifnex-receiver-city">
|
||||
<div class="ifnex-address-warning" id="ifnex-receiver-city-warning" style="display:none;">
|
||||
⚠️ نام شهر را به زبان انگلیسی وارد کنید. در صورت عدم توانایی، با پشتیبانی تماس بگیرید.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ifnex-field full">
|
||||
<label>آدرس کامل *</label>
|
||||
<textarea id="ifnex-receiver-address" rows="2"></textarea>
|
||||
<div class="ifnex-address-warning" id="ifnex-receiver-address-warning" style="display:none;">
|
||||
⚠️ آدرس را به زبان انگلیسی وارد کنید (برای حمل بینالمللی). در صورت عدم توانایی، با پشتیبانی تماس بگیرید.
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@ -956,11 +826,8 @@ function ifnex_order_payment_shortcode($atts) {
|
||||
btn.prop('disabled', false).text('انتقال به درگاه بانکی');
|
||||
$('#ifnex-payment-message')
|
||||
.addClass('ifnex-error-box')
|
||||
var errMsg = res.data;
|
||||
if (typeof errMsg === 'object') {
|
||||
errMsg = errMsg.message || JSON.stringify(errMsg);
|
||||
}
|
||||
$('#ifnex-payment-message').addClass('ifnex-error-box').text('خطا: ' + (errMsg || 'خطا در اتصال به درگاه')).show();
|
||||
.text('خطا: ' + (res.data || 'خطا در اتصال به درگاه'))
|
||||
.show();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
@ -1270,7 +1137,6 @@ function ifnex_icon($name, $size = 20) {
|
||||
'truck' => '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"/>',
|
||||
'check' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>',
|
||||
'clock' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>',
|
||||
'bell' => '<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"/>',
|
||||
];
|
||||
|
||||
$path = $icons[$name] ?? $icons['dashboard'];
|
||||
@ -1298,7 +1164,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
||||
}
|
||||
|
||||
$tab = sanitize_key($_GET['tab'] ?? 'dashboard');
|
||||
$allowed_tabs = ['dashboard', 'orders', 'new-order', 'wallet', 'transactions', 'notifications', 'tracking', 'profile'];
|
||||
$allowed_tabs = ['dashboard', 'orders', 'new-order', 'wallet', 'transactions', 'tracking', 'profile'];
|
||||
if (!in_array($tab, $allowed_tabs)) $tab = 'dashboard';
|
||||
|
||||
$bridge = new IFNEX_User_Bridge();
|
||||
@ -1326,14 +1192,12 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
||||
'wallet' => ['icon' => 'wallet', 'label' => 'کیف پول'],
|
||||
'transactions' => ['icon' => 'chart', 'label' => 'تراکنشها'],
|
||||
'tracking' => ['icon' => 'tracking', 'label' => 'رهگیری مرسوله'],
|
||||
'notifications' => ['icon' => 'bell', 'label' => 'اعلانها'],
|
||||
'profile' => ['icon' => 'user', 'label' => 'پروفایل'],
|
||||
];
|
||||
|
||||
$tab_titles = [
|
||||
'dashboard' => 'داشبورد', 'orders' => 'سفارشات من', 'new-order' => 'ثبت سفارش جدید',
|
||||
'wallet' => 'کیف پول', 'transactions' => 'تراکنشها', 'notifications' => 'اعلانها',
|
||||
'tracking' => 'رهگیری مرسوله', 'profile' => 'پروفایل',
|
||||
'wallet' => 'کیف پول', 'transactions' => 'تراکنشها', 'tracking' => 'رهگیری مرسوله', 'profile' => 'پروفایل',
|
||||
];
|
||||
|
||||
ob_start();
|
||||
@ -1353,26 +1217,6 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
||||
<span class="ifnx-menu-icon"><?php echo ifnex_icon($item['icon'], 19); ?></span>
|
||||
<span><?php echo esc_html($item['label']); ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php
|
||||
// دریافت تعداد نوتیفیکیشنهای خواندهنشده
|
||||
$unread_count = 0;
|
||||
$notif_result = $bridge->get_notifications($user_id, 1);
|
||||
if (!is_wp_error($notif_result)) {
|
||||
$unread_count = $notif_result['unread_count'] ?? 0;
|
||||
}
|
||||
?>
|
||||
<?php foreach ($menu_items as $key => $item): ?>
|
||||
<a href="<?php echo esc_url(add_query_arg('tab', $key, $base_url)); ?>"
|
||||
class="ifnx-menu-item <?php echo $tab === $key ? 'active' : ''; ?>">
|
||||
<span class="ifnx-menu-icon" style="position:relative;">
|
||||
<?php echo ifnex_icon($item['icon'], 19); ?>
|
||||
<?php if ($key === 'notifications' && $unread_count > 0): ?>
|
||||
<span class="ifnx-notif-badge"><?php echo $unread_count > 9 ? '9+' : $unread_count; ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<span><?php echo esc_html($item['label']); ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<div class="ifnx-menu-divider"></div>
|
||||
<a href="<?php echo esc_url(wp_logout_url(home_url())); ?>" class="ifnx-menu-item logout">
|
||||
@ -1467,35 +1311,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($tab === 'transactions'): ?>
|
||||
<?php echo do_shortcode('[ifnex_transactions per_page="20"]'); ?>
|
||||
<?php elseif ($tab === 'notifications'): ?>
|
||||
<?php
|
||||
$all_notifications = $bridge->get_notifications($user_id, 30);
|
||||
if (is_wp_error($all_notifications)):
|
||||
?>
|
||||
<div class="ifnx-card"><div class="ifnx-empty"><p>خطا در دریافت اعلانها.</p></div></div>
|
||||
<?php elseif (empty($all_notifications['data'])): ?>
|
||||
<div class="ifnx-card"><div class="ifnx-empty">
|
||||
<?php echo ifnex_icon('bell', 40); ?>
|
||||
<p>اعلان جدیدی وجود ندارد.</p>
|
||||
</div></div>
|
||||
<?php else: ?>
|
||||
<div class="ifnx-notif-list">
|
||||
<?php foreach ($all_notifications['data'] as $notif): ?>
|
||||
<div class="ifnx-notif-item <?php echo empty($notif['read_at']) ? 'unread' : ''; ?>" data-id="<?php echo esc_attr($notif['id']); ?>">
|
||||
<div class="ifnx-notif-dot"></div>
|
||||
<div class="ifnx-notif-body">
|
||||
<div class="ifnx-notif-title"><?php echo esc_html($notif['title'] ?? $notif['data']['title'] ?? 'اعلان'); ?></div>
|
||||
<div class="ifnx-notif-message"><?php echo esc_html($notif['body'] ?? $notif['data']['message'] ?? ''); ?></div>
|
||||
<?php if (!empty($notif['data']['shipment_awb'])): ?>
|
||||
<a href="<?php echo esc_url(add_query_arg(['tab' => 'orders', 'view' => $notif['data']['shipment_id']], $base_url)); ?>" class="ifnx-notif-link">مشاهده سفارش</a>
|
||||
<?php endif; ?>
|
||||
<div class="ifnx-notif-time"><?php echo esc_html($notif['created_at_jalali'] ?? $notif['created_at'] ?? ''); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php echo do_shortcode('[ifnex_transactions]'); ?>
|
||||
<?php elseif ($tab === 'tracking'): ?>
|
||||
<?php echo do_shortcode('[ifnex_tracking_form]'); ?>
|
||||
<?php elseif ($tab === 'profile'): ?>
|
||||
|
||||
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Customer;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Shipment;
|
||||
use App\Models\ShipmentItem;
|
||||
use App\Models\ShipmentPackage;
|
||||
use App\Models\Country;
|
||||
use App\Enums\ShipmentStatus;
|
||||
use App\Enums\ShipmentDirection;
|
||||
@ -16,7 +17,6 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Models\WalletTransaction;
|
||||
|
||||
class CustomerOrderController extends Controller
|
||||
{
|
||||
@ -76,11 +76,17 @@ class CustomerOrderController extends Controller
|
||||
'from_country_id' => ['required', 'exists:countries,id'],
|
||||
'to_country_id' => ['required', 'exists:countries,id'],
|
||||
|
||||
// وزن و ابعاد
|
||||
'weight' => ['required', 'numeric', 'min:0.1'],
|
||||
// وزن و ابعاد (فیلدهای کلی سفارش - برای backward compatibility)
|
||||
'weight' => ['nullable', 'numeric', 'min:0.1'],
|
||||
'volumetric_weight' => ['nullable', 'numeric', 'min:0'],
|
||||
'dimensions' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
// بستهها (Multi-Package) — حداقل ۱، حداکثر ۱۰ بسته
|
||||
'packages' => ['nullable', 'array', 'min:1', 'max:10'],
|
||||
'packages.*.weight' => ['required_with:packages', 'numeric', 'min:0.1'],
|
||||
'packages.*.dimensions' => ['nullable', 'string', 'max:50'],
|
||||
'packages.*.description' => ['nullable', 'string', 'max:500'],
|
||||
|
||||
// اطلاعات فرستنده
|
||||
'sender_name' => ['required', 'string', 'max:255'],
|
||||
'sender_company' => ['nullable', 'string', 'max:255'],
|
||||
@ -136,6 +142,29 @@ class CustomerOrderController extends Controller
|
||||
], 403);
|
||||
}
|
||||
|
||||
// محاسبه وزن کل از بستهها (اگر وجود داره)
|
||||
$packages = $validated['packages'] ?? [];
|
||||
$totalWeight = 0;
|
||||
$totalVolumetricWeight = 0;
|
||||
|
||||
if (!empty($packages)) {
|
||||
foreach ($packages as $pkg) {
|
||||
$totalWeight += (float) $pkg['weight'];
|
||||
$volWeight = ShipmentPackage::calculateVolumetricWeight($pkg['dimensions'] ?? null);
|
||||
$totalVolumetricWeight += $volWeight;
|
||||
}
|
||||
// Override وزن کلی سفارش
|
||||
$validated['weight'] = $totalWeight;
|
||||
$validated['volumetric_weight'] = $totalVolumetricWeight;
|
||||
$validated['chargeable_weight'] = max($totalWeight, $totalVolumetricWeight);
|
||||
} else {
|
||||
// حالت قدیمی: یه بسته
|
||||
$validated['chargeable_weight'] = max(
|
||||
(float) $validated['weight'],
|
||||
(float) ($validated['volumetric_weight'] ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
// دریافت کشور مقصد برای محاسبه قیمت
|
||||
$destinationCountry = Country::findOrFail(
|
||||
$validated['direction'] === 'export'
|
||||
@ -229,6 +258,35 @@ class CustomerOrderController extends Controller
|
||||
'receiver_id_number' => $validated['receiver_id_number'] ?? null,
|
||||
]);
|
||||
|
||||
// ذخیره بستهها (Multi-Package)
|
||||
if (!empty($validated['packages'])) {
|
||||
foreach ($validated['packages'] as $index => $pkg) {
|
||||
$volWeight = ShipmentPackage::calculateVolumetricWeight($pkg['dimensions'] ?? null);
|
||||
$chargeable = max((float) $pkg['weight'], $volWeight);
|
||||
|
||||
ShipmentPackage::create([
|
||||
'shipment_id' => $shipment->id,
|
||||
'package_number' => $index + 1,
|
||||
'weight' => $pkg['weight'],
|
||||
'volumetric_weight' => $volWeight,
|
||||
'chargeable_weight' => $chargeable,
|
||||
'dimensions' => $pkg['dimensions'] ?? null,
|
||||
'description' => $pkg['description'] ?? null,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// حالت قدیمی: یه بسته با وزن کلی سفارش
|
||||
ShipmentPackage::create([
|
||||
'shipment_id' => $shipment->id,
|
||||
'package_number' => 1,
|
||||
'weight' => $validated['weight'],
|
||||
'volumetric_weight' => $validated['volumetric_weight'] ?? 0,
|
||||
'chargeable_weight' => $validated['chargeable_weight'],
|
||||
'dimensions' => $validated['dimensions'] ?? null,
|
||||
'description' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ذخیره اقلام گمرکی
|
||||
if (!empty($validated['items'])) {
|
||||
foreach ($validated['items'] as $index => $item) {
|
||||
@ -372,7 +430,7 @@ class CustomerOrderController extends Controller
|
||||
*/
|
||||
public function countries(): JsonResponse
|
||||
{
|
||||
$countries = Country::select('id', 'name', 'iso_code', 'calling_code', 'export_zone_parcel', 'import_zone_parcel')
|
||||
$countries = Country::select('id', 'name', 'iso_code', 'export_zone_parcel', 'import_zone_parcel')
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@ -465,62 +523,6 @@ class CustomerOrderController extends Controller
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* نوتیفیکیشنهای کاربر
|
||||
* GET /api/v1/customer/notifications
|
||||
*/
|
||||
public function notifications(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$notifications = $user->notifications()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->per_page ?? 20);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => collect($notifications->items())->map(function ($notification) {
|
||||
$data = $notification->data;
|
||||
return [
|
||||
'id' => $notification->id,
|
||||
'type' => $notification->type,
|
||||
'title' => $data['title'] ?? 'اعلان',
|
||||
'body' => $data['message'] ?? '',
|
||||
'data' => $data,
|
||||
'read_at' => $notification->read_at?->toIso8601String(),
|
||||
'created_at' => $notification->created_at->toIso8601String(),
|
||||
'created_at_jalali' => $this->toJalali($notification->created_at),
|
||||
];
|
||||
}),
|
||||
'unread_count' => $user->unreadNotifications()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* خواندن نوتیفیکیشن
|
||||
* POST /api/v1/customer/notifications/{notification}/read
|
||||
*/
|
||||
public function markNotificationRead(Request $request, $notification): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$notif = $user->notifications()->where('id', $notification)->first();
|
||||
|
||||
if (!$notif) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'نوتیفیکیشن یافت نشد.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$notif->markAsRead();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'خوانده شد.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* تولید شماره AWB
|
||||
*/
|
||||
|
||||
@ -9,47 +9,61 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
class Shipment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'awb_no', 'forwarder', 'direction', 'type', 'status', 'reason_for_export',
|
||||
'weight', 'volumetric_weight', 'chargeable_weight', 'dimensions',
|
||||
'shipping_price', 'extra_service', 'packing_cost', 'domestic_pickup', 'domestic_delivery',
|
||||
'warehousing_cost', 'vat_amount', 'discount', 'total_fee', 'net_dirham', 'net_rial',
|
||||
'from_country_id', 'to_country_id',
|
||||
'sender_name', 'sender_company', 'sender_phone', 'sender_email',
|
||||
'sender_address', 'sender_city', 'sender_state', 'sender_zip', 'sender_id_number',
|
||||
'receiver_name', 'receiver_company', 'receiver_phone', 'receiver_email',
|
||||
'receiver_address', 'receiver_city', 'receiver_state', 'receiver_zip', 'receiver_id_number',
|
||||
'customer_notes', 'cod_amount', 'declared_value', 'content_description',
|
||||
'awb_no',
|
||||
'forwarder',
|
||||
'direction',
|
||||
'type',
|
||||
'status',
|
||||
'from_country_id',
|
||||
'to_country_id',
|
||||
// Weight & Dimensions
|
||||
'weight',
|
||||
'volumetric_weight',
|
||||
'chargeable_weight',
|
||||
'dimensions',
|
||||
// Financial
|
||||
'shipping_price',
|
||||
'extra_service',
|
||||
'domestic_pickup',
|
||||
'packing_cost',
|
||||
'domestic_delivery',
|
||||
'warehousing_cost',
|
||||
'discount',
|
||||
'vat_amount',
|
||||
'total_fee',
|
||||
'net_dirham',
|
||||
'net_rial',
|
||||
'invoice_total_usd',
|
||||
// Sender
|
||||
'sender_name',
|
||||
'sender_company',
|
||||
'sender_phone',
|
||||
'sender_email',
|
||||
'sender_address',
|
||||
'sender_city',
|
||||
'sender_zip',
|
||||
'sender_id_number',
|
||||
// Receiver
|
||||
'receiver_name',
|
||||
'receiver_company',
|
||||
'receiver_phone',
|
||||
'receiver_email',
|
||||
'receiver_address',
|
||||
'receiver_city',
|
||||
'receiver_zip',
|
||||
'receiver_id_number',
|
||||
// Customs
|
||||
'reason_for_export',
|
||||
'content_description',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'direction' => \App\Enums\ShipmentDirection::class,
|
||||
'type' => \App\Enums\ShipmentType::class,
|
||||
'status' => \App\Enums\ShipmentStatus::class,
|
||||
'weight' => 'decimal:2',
|
||||
'volumetric_weight' => 'decimal:2',
|
||||
'chargeable_weight' => 'decimal:2',
|
||||
'shipping_price' => 'decimal:2',
|
||||
'extra_service' => 'decimal:2',
|
||||
'packing_cost' => 'decimal:2',
|
||||
'domestic_pickup' => 'decimal:2',
|
||||
'domestic_delivery' => 'decimal:2',
|
||||
'warehousing_cost' => 'decimal:2',
|
||||
'vat_amount' => 'decimal:2',
|
||||
'discount' => 'decimal:2',
|
||||
'total_fee' => 'decimal:2',
|
||||
'net_dirham' => 'decimal:2',
|
||||
'net_rial' => 'decimal:2',
|
||||
'cod_amount' => 'decimal:2',
|
||||
'declared_value' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
protected $casts = [
|
||||
'weight' => 'decimal:3',
|
||||
'volumetric_weight' => 'decimal:3',
|
||||
'chargeable_weight' => 'decimal:3',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
// === Relationships ===
|
||||
|
||||
public function fromCountry(): BelongsTo
|
||||
{
|
||||
@ -76,28 +90,49 @@ class Shipment extends Model
|
||||
return $this->hasMany(ShipmentTrackingEvent::class);
|
||||
}
|
||||
|
||||
public function statusHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShipmentStatusHistory::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function isParcel(): bool
|
||||
{
|
||||
return $this->type === \App\Enums\ShipmentType::Parcel;
|
||||
}
|
||||
|
||||
public function isDocument(): bool
|
||||
{
|
||||
return $this->type !== \App\Enums\ShipmentType::Parcel;
|
||||
}
|
||||
|
||||
public function getInvoiceTotalUsdAttribute(): float
|
||||
{
|
||||
return $this->items->sum('total_usd');
|
||||
}
|
||||
|
||||
/**
|
||||
* بستههای مرسوله (Multi-Package)
|
||||
*/
|
||||
public function packages(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShipmentPackage::class)->orderBy('package_no');
|
||||
return $this->hasMany(ShipmentPackage::class);
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeByAwb($query, string $awbNo)
|
||||
{
|
||||
return $query->where('awb_no', $awbNo);
|
||||
}
|
||||
|
||||
public function scopeExport($query)
|
||||
{
|
||||
return $query->where('direction', 'export');
|
||||
}
|
||||
|
||||
public function scopeImport($query)
|
||||
{
|
||||
return $query->where('direction', 'import');
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/**
|
||||
* دریافت آخرین رویداد ترکینگ
|
||||
*/
|
||||
public function latestTrackingEvent()
|
||||
{
|
||||
return $this->trackingEvents()
|
||||
->orderBy('event_date', 'desc')
|
||||
->orderBy('event_time', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* آیا مرسوله تحویل داده شده؟
|
||||
*/
|
||||
public function isDelivered(): bool
|
||||
{
|
||||
return $this->status === 'delivered';
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,22 +8,56 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
class ShipmentPackage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'shipment_id', 'package_no', 'weight', 'volumetric_weight',
|
||||
'chargeable_weight', 'dimensions', 'declared_value', 'content_description',
|
||||
'shipment_id',
|
||||
'package_number',
|
||||
'weight',
|
||||
'volumetric_weight',
|
||||
'chargeable_weight',
|
||||
'dimensions',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'weight' => 'decimal:2',
|
||||
'volumetric_weight' => 'decimal:2',
|
||||
'chargeable_weight' => 'decimal:2',
|
||||
'declared_value' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
protected $casts = [
|
||||
'weight' => 'decimal:3',
|
||||
'volumetric_weight' => 'decimal:3',
|
||||
'chargeable_weight' => 'decimal:3',
|
||||
];
|
||||
|
||||
/**
|
||||
* رابطه با Shipment
|
||||
*/
|
||||
public function shipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Shipment::class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* محاسبه وزن قابل محاسبه (ماکزیمم وزن واقعی و حجمی)
|
||||
*/
|
||||
public function calculateChargeableWeight(): float
|
||||
{
|
||||
return max((float) $this->weight, (float) $this->volumetric_weight);
|
||||
}
|
||||
|
||||
/**
|
||||
* محاسبه وزن حجمی از ابعاد
|
||||
* فرمول: (طول × عرض × ارتفاع) / 5000
|
||||
*/
|
||||
public static function calculateVolumetricWeight(?string $dimensions): float
|
||||
{
|
||||
if (!$dimensions) return 0;
|
||||
|
||||
// پارس کردن ابعاد - فرمتهای ممکن: "25*15*3" یا "25x15x3" یا "25,15,3"
|
||||
$parts = preg_split('/[\*xX,]/', trim($dimensions));
|
||||
if (count($parts) !== 3) return 0;
|
||||
|
||||
$length = (float) trim($parts[0]);
|
||||
$width = (float) trim($parts[1]);
|
||||
$height = (float) trim($parts[2]);
|
||||
|
||||
if ($length <= 0 || $width <= 0 || $height <= 0) return 0;
|
||||
|
||||
// فرمول استاندارد IATA: (L × W × H) / 5000
|
||||
return round(($length * $width * $height) / 5000, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('shipment_packages')) {
|
||||
Schema::create('shipment_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('shipment_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedTinyInteger('package_number')->default(1); // ۱، ۲، ۳، ...
|
||||
$table->decimal('weight', 10, 3)->default(0); // وزن واقعی (kg)
|
||||
$table->decimal('volumetric_weight', 10, 3)->default(0); // وزن حجمی (kg)
|
||||
$table->decimal('chargeable_weight', 10, 3)->default(0); // وزن قابل محاسبه
|
||||
$table->string('dimensions', 50)->nullable(); // ابعاد W*L*H (cm)
|
||||
$table->text('description')->nullable(); // توضیحات بسته
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('shipment_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('shipment_packages');
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user