feat(api): implement shipment review and resubmission workflow
Introduces a complete shipment review system allowing staff to request changes to customer orders and customers to resubmit corrected orders. - Add `ReviewState` enum and `ShipmentReview` model to track review history. - Implement `ShipmentReviewService` to handle approval and change request logic. - Add `resubmit` endpoint for customers to update orders when `changes_requested` state is active. - Add `request-changes` endpoint for staff to flag orders for correction. - Update `ShipmentResource` in Filament to display review states and manage approvals. - Implement WordPress bridge support for fetching and resubmitting orders via AJAX. - Add database migrations for `shipment_reviews` table and `review_state` column on shipments. - Add `StaffApiMiddleware` to secure staff-specific API routes.
This commit is contained in:
parent
69aa7d1aa9
commit
61a2643dd0
@ -7,6 +7,9 @@ jQuery(document).ready(function($) {
|
|||||||
var packages = []; // آرایه بستهها
|
var packages = []; // آرایه بستهها
|
||||||
var invoiceItems = []; // آرایه اقلام گمرکی
|
var invoiceItems = []; // آرایه اقلام گمرکی
|
||||||
var MAX_ITEMS = 9; // حداکثر تعداد اقلام
|
var MAX_ITEMS = 9; // حداکثر تعداد اقلام
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
var resubmitOrderId = parseInt(urlParams.get('resubmit') || '0', 10);
|
||||||
|
var isResubmitMode = resubmitOrderId > 0;
|
||||||
|
|
||||||
// ─── Load Countries ───
|
// ─── Load Countries ───
|
||||||
function loadCountries() {
|
function loadCountries() {
|
||||||
@ -34,6 +37,9 @@ jQuery(document).ready(function($) {
|
|||||||
$('#ifnex-to-country').val(iran.id).trigger('change');
|
$('#ifnex-to-country').val(iran.id).trigger('change');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isResubmitMode) {
|
||||||
|
loadOrderForResubmit(resubmitOrderId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('IFNEX: Failed to load countries', res);
|
console.error('IFNEX: Failed to load countries', res);
|
||||||
showFormError('خطا در دریافت لیست کشورها. لطفاً صفحه را دوباره بارگذاری کنید.');
|
showFormError('خطا در دریافت لیست کشورها. لطفاً صفحه را دوباره بارگذاری کنید.');
|
||||||
@ -46,6 +52,222 @@ jQuery(document).ready(function($) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadOrderForResubmit(orderId) {
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: ifnex_ajax.ajax_url,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'ifnex_get_resubmit_order',
|
||||||
|
order_id: orderId,
|
||||||
|
nonce: ifnex_ajax.nonce
|
||||||
|
},
|
||||||
|
|
||||||
|
success: function(res) {
|
||||||
|
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
console.error('IFNEX: Failed to load resubmit order', res);
|
||||||
|
showFormError(res.data || 'اطلاعات سفارش برای اصلاح دریافت نشد.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = res.data;
|
||||||
|
|
||||||
|
console.log('IFNEX: Resubmit order loaded', data);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// اطلاعات اصلی سفارش
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (data.direction) {
|
||||||
|
$('#ifnex-direction').val(data.direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type) {
|
||||||
|
$('#ifnex-type').val(data.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// کشورها
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
var fromCountryId = data.from_country && data.from_country.id
|
||||||
|
? data.from_country.id
|
||||||
|
: '';
|
||||||
|
|
||||||
|
var toCountryId = data.to_country && data.to_country.id
|
||||||
|
? data.to_country.id
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// اگر ID در پاسخ نبود، از ISO پیدا میکنیم
|
||||||
|
if (!fromCountryId && data.from_country && data.from_country.iso_code) {
|
||||||
|
var fromCountry = countries.find(function(c) {
|
||||||
|
return c.iso_code === data.from_country.iso_code;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fromCountry) {
|
||||||
|
fromCountryId = fromCountry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toCountryId && data.to_country && data.to_country.iso_code) {
|
||||||
|
var toCountry = countries.find(function(c) {
|
||||||
|
return c.iso_code === data.to_country.iso_code;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toCountry) {
|
||||||
|
toCountryId = toCountry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#ifnex-from-country').val(fromCountryId).trigger('change');
|
||||||
|
$('#ifnex-to-country').val(toCountryId).trigger('change');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// اطلاعات فرستنده
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (data.sender) {
|
||||||
|
$('#ifnex-sender-name').val(data.sender.name || '');
|
||||||
|
$('#ifnex-sender-phone').val(data.sender.phone || '');
|
||||||
|
$('#ifnex-sender-city').val(data.sender.city || '');
|
||||||
|
$('#ifnex-sender-address').val(data.sender.address || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// اطلاعات گیرنده
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (data.receiver) {
|
||||||
|
$('#ifnex-receiver-name').val(data.receiver.name || '');
|
||||||
|
$('#ifnex-receiver-phone').val(data.receiver.phone || '');
|
||||||
|
$('#ifnex-receiver-city').val(data.receiver.city || '');
|
||||||
|
$('#ifnex-receiver-address').val(data.receiver.address || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// بستهها
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
packages = [];
|
||||||
|
|
||||||
|
if (Array.isArray(data.packages) && data.packages.length > 0) {
|
||||||
|
|
||||||
|
data.packages.forEach(function(pkg, index) {
|
||||||
|
|
||||||
|
var dimensions = pkg.dimensions || '';
|
||||||
|
|
||||||
|
var apiWeight = parseFloat(pkg.weight);
|
||||||
|
var apiVolumetricWeight = parseFloat(pkg.volumetric_weight);
|
||||||
|
var apiChargeableWeight = parseFloat(pkg.chargeable_weight);
|
||||||
|
|
||||||
|
var calculatedVolumetricWeight = calculateVolumetricWeight(dimensions);
|
||||||
|
|
||||||
|
packages.push({
|
||||||
|
id: 'resubmit_pkg_' + Date.now() + '_' + index,
|
||||||
|
number: parseInt(pkg.package_no || (index + 1), 10),
|
||||||
|
|
||||||
|
// مقدار واقعی از Laravel
|
||||||
|
weight: isNaN(apiWeight) ? '' : apiWeight,
|
||||||
|
|
||||||
|
dimensions: dimensions,
|
||||||
|
|
||||||
|
// اولویت با مقدار ذخیرهشده در Laravel
|
||||||
|
// fallback به محاسبه سمت Frontend
|
||||||
|
volumetricWeight: !isNaN(apiVolumetricWeight)
|
||||||
|
? apiVolumetricWeight
|
||||||
|
: calculatedVolumetricWeight,
|
||||||
|
|
||||||
|
// برای حفظ مقدار موجود و استفاده در UI
|
||||||
|
chargeableWeight: !isNaN(apiChargeableWeight)
|
||||||
|
? apiChargeableWeight
|
||||||
|
: Math.max(
|
||||||
|
isNaN(apiWeight) ? 0 : apiWeight,
|
||||||
|
!isNaN(apiVolumetricWeight)
|
||||||
|
? apiVolumetricWeight
|
||||||
|
: calculatedVolumetricWeight
|
||||||
|
),
|
||||||
|
|
||||||
|
description: pkg.description || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
addPackage();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAllPackages();
|
||||||
|
updateSummary();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// اقلام گمرکی
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
invoiceItems = [];
|
||||||
|
|
||||||
|
if (Array.isArray(data.items) && data.items.length > 0) {
|
||||||
|
|
||||||
|
data.items.forEach(function(item, index) {
|
||||||
|
|
||||||
|
invoiceItems.push({
|
||||||
|
id: 'resubmit_item_' + Date.now() + '_' + index,
|
||||||
|
number: index + 1,
|
||||||
|
description: item.description || '',
|
||||||
|
hsCode: item.hs_code || '',
|
||||||
|
quantity: item.quantity || '',
|
||||||
|
unitPrice: item.unit_price || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInvoiceStepVisibility();
|
||||||
|
renderAllInvoiceItems();
|
||||||
|
updateItemsSummary();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// عنوان و وضعیت فرم
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
$('#ifnex-submit-order').text('✏️ اصلاح و ارسال مجدد سفارش');
|
||||||
|
|
||||||
|
var $form = $('#ifnex-order-form');
|
||||||
|
|
||||||
|
if ($form.find('.ifnex-resubmit-notice').length === 0) {
|
||||||
|
|
||||||
|
$form.prepend(
|
||||||
|
'<div class="ifnex-warning-box ifnex-resubmit-notice" style="margin-bottom:20px;">' +
|
||||||
|
'<strong>✏️ اصلاح سفارش</strong>' +
|
||||||
|
'<p style="margin:8px 0 0;">' +
|
||||||
|
'اطلاعات سفارش قبلی در فرم قرار گرفته است. موارد موردنظر را اصلاح کنید و سپس قیمت را دوباره محاسبه و سفارش را ارسال کنید.' +
|
||||||
|
'</p>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('IFNEX: Resubmit form populated successfully');
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
error: function(xhr) {
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
'IFNEX: AJAX error loading resubmit order',
|
||||||
|
xhr.status,
|
||||||
|
xhr.responseText
|
||||||
|
);
|
||||||
|
|
||||||
|
showFormError('خطا در دریافت اطلاعات سفارش برای اصلاح.');
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function populateCountrySelects() {
|
function populateCountrySelects() {
|
||||||
var $from = $('#ifnex-from-country');
|
var $from = $('#ifnex-from-country');
|
||||||
var $to = $('#ifnex-to-country');
|
var $to = $('#ifnex-to-country');
|
||||||
@ -197,7 +419,13 @@ jQuery(document).ready(function($) {
|
|||||||
// وزن حجمی (محاسبه خودکار - فقط نمایش)
|
// وزن حجمی (محاسبه خودکار - فقط نمایش)
|
||||||
html += '<div class="ifnex-field">';
|
html += '<div class="ifnex-field">';
|
||||||
html += '<label for="ifnex-pkg-volumetric-' + pkg.id + '">وزن حجمی (kg)</label>';
|
html += '<label for="ifnex-pkg-volumetric-' + pkg.id + '">وزن حجمی (kg)</label>';
|
||||||
html += '<input type="text" id="ifnex-pkg-volumetric-' + pkg.id + '" class="ifnex-pkg-volumetric-display" name="packages[' + pkg.number + '][volumetric_weight]" data-pkg-id="' + pkg.id + '" value="0" readonly style="background: #f0f9ff; color: #0369a1; font-weight: bold;">';
|
var volumetricDisplay = parseFloat(pkg.volumetricWeight);
|
||||||
|
|
||||||
|
if (isNaN(volumetricDisplay) || volumetricDisplay <= 0) {
|
||||||
|
volumetricDisplay = calculateVolumetricWeight(pkg.dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<input type="text" id="ifnex-pkg-volumetric-' + pkg.id + '" class="ifnex-pkg-volumetric-display" name="packages[' + pkg.number + '][volumetric_weight]" data-pkg-id="' + pkg.id + '" value="' + volumetricDisplay.toFixed(3) + '" readonly style="background: #f0f9ff; color: #0369a1; font-weight: bold;">';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// توضیحات
|
// توضیحات
|
||||||
@ -227,11 +455,24 @@ jQuery(document).ready(function($) {
|
|||||||
var hasWeight = false;
|
var hasWeight = false;
|
||||||
|
|
||||||
packages.forEach(function(pkg) {
|
packages.forEach(function(pkg) {
|
||||||
if (pkg.weight && pkg.weight > 0) {
|
var weight = parseFloat(pkg.weight) || 0;
|
||||||
totalWeight += parseFloat(pkg.weight);
|
|
||||||
|
var volumetricWeight = parseFloat(pkg.volumetricWeight);
|
||||||
|
|
||||||
|
if (isNaN(volumetricWeight) || volumetricWeight <= 0) {
|
||||||
|
volumetricWeight = calculateVolumetricWeight(pkg.dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weight > 0) {
|
||||||
|
totalWeight += weight;
|
||||||
hasWeight = true;
|
hasWeight = true;
|
||||||
}
|
}
|
||||||
totalVolumetric += pkg.volumetricWeight || 0;
|
|
||||||
|
totalVolumetric += volumetricWeight;
|
||||||
|
|
||||||
|
// state را نیز normalize میکنیم
|
||||||
|
pkg.weight = weight;
|
||||||
|
pkg.volumetricWeight = volumetricWeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
var chargeable = Math.max(totalWeight, totalVolumetric);
|
var chargeable = Math.max(totalWeight, totalVolumetric);
|
||||||
@ -749,29 +990,118 @@ jQuery(document).ready(function($) {
|
|||||||
|
|
||||||
// ─── Submit Order ───
|
// ─── Submit Order ───
|
||||||
$('#ifnex-submit-order').on('click', function() {
|
$('#ifnex-submit-order').on('click', function() {
|
||||||
|
|
||||||
var btn = $(this);
|
var btn = $(this);
|
||||||
btn.prop('disabled', true).text('در حال ثبت...');
|
|
||||||
|
var payload = {
|
||||||
|
action: isResubmitMode
|
||||||
|
? 'ifnex_resubmit_order'
|
||||||
|
: 'ifnex_submit_order',
|
||||||
|
|
||||||
|
nonce: ifnex_ajax.nonce,
|
||||||
|
|
||||||
|
data: buildOrderData()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isResubmitMode) {
|
||||||
|
payload.order_id = resubmitOrderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.prop('disabled', true).text(
|
||||||
|
isResubmitMode
|
||||||
|
? 'در حال ارسال مجدد...'
|
||||||
|
: 'در حال ثبت...'
|
||||||
|
);
|
||||||
|
|
||||||
|
$('#ifnex-submit-error').hide();
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
|
|
||||||
url: ifnex_ajax.ajax_url,
|
url: ifnex_ajax.ajax_url,
|
||||||
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { action: 'ifnex_submit_order', nonce: ifnex_ajax.nonce, data: buildOrderData() },
|
|
||||||
|
data: payload,
|
||||||
|
|
||||||
success: function(res) {
|
success: function(res) {
|
||||||
btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش');
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
var orderId = res.data.data.id || res.data.id;
|
|
||||||
// با فلوی جدید، کاربر به لیست سفارشات هدایت میشود
|
var orderId = res.data && (
|
||||||
// (چون پرداخت فقط بعد از تأیید کارمند امکانپذیر است)
|
res.data.order_id ||
|
||||||
window.location.href = ifnex_ajax.orders_url;
|
(res.data.data && res.data.data.id) ||
|
||||||
|
res.data.id
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
isResubmitMode
|
||||||
|
? 'IFNEX: Order resubmitted successfully'
|
||||||
|
: 'IFNEX: Order created successfully',
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
// در resubmit، بهتر است مستقیماً به جزئیات سفارش برگردیم
|
||||||
|
// تا وضعیت جدید بررسی و تاریخچه آن دیده شود.
|
||||||
|
if (
|
||||||
|
isResubmitMode &&
|
||||||
|
res.data &&
|
||||||
|
res.data.redirect_url
|
||||||
|
) {
|
||||||
|
|
||||||
|
window.location.href = res.data.redirect_url;
|
||||||
|
|
||||||
|
} else if (isResubmitMode && orderId) {
|
||||||
|
|
||||||
|
window.location.href =
|
||||||
|
ifnex_ajax.orders_url +
|
||||||
|
'?order_id=' +
|
||||||
|
encodeURIComponent(orderId);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// فلوی ثبت سفارش عادی بدون تغییر باقی میماند
|
||||||
|
window.location.href = ifnex_ajax.orders_url;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
$('#ifnex-submit-error').text(res.data || 'خطا در ثبت سفارش').show();
|
|
||||||
|
btn.prop('disabled', false).text(
|
||||||
|
isResubmitMode
|
||||||
|
? '✏️ اصلاح و ارسال مجدد سفارش'
|
||||||
|
: '✅ تأیید و ثبت سفارش'
|
||||||
|
);
|
||||||
|
|
||||||
|
$('#ifnex-submit-error')
|
||||||
|
.text(res.data || 'خطا در ثبت سفارش')
|
||||||
|
.show();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
error: function() {
|
|
||||||
btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش');
|
error: function(xhr) {
|
||||||
$('#ifnex-submit-error').text('خطا در ارتباط با سرور').show();
|
|
||||||
|
console.error(
|
||||||
|
'IFNEX: Order submit AJAX error',
|
||||||
|
xhr.status,
|
||||||
|
xhr.responseText
|
||||||
|
);
|
||||||
|
|
||||||
|
btn.prop('disabled', false).text(
|
||||||
|
isResubmitMode
|
||||||
|
? '✏️ اصلاح و ارسال مجدد سفارش'
|
||||||
|
: '✅ تأیید و ثبت سفارش'
|
||||||
|
);
|
||||||
|
|
||||||
|
$('#ifnex-submit-error')
|
||||||
|
.text('خطا در ارتباط با سرور')
|
||||||
|
.show();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Auto-select Iran based on direction ───
|
// ─── Auto-select Iran based on direction ───
|
||||||
|
|||||||
@ -1030,6 +1030,80 @@ function ifnex_order_detail_shortcode($atts) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($data['review'])): ?>
|
||||||
|
<div class="ifnex-detail-section ifnex-review-section">
|
||||||
|
<div class="ifnex-review-header">
|
||||||
|
<h3>🔎 وضعیت بررسی سفارش</h3>
|
||||||
|
|
||||||
|
<span class="ifnex-badge ifnex-badge-<?php echo esc_attr($data['review']['color'] ?? ''); ?>">
|
||||||
|
<?php echo esc_html($data['review']['label'] ?? 'نامشخص'); ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (($data['review']['state'] ?? '') === 'changes_requested' && !empty($data['review']['latest'])): ?>
|
||||||
|
<div class="ifnex-warning-box">
|
||||||
|
<h4>⚠️ نیاز به اصلاح اطلاعات سفارش</h4>
|
||||||
|
|
||||||
|
<?php if (!empty($data['review']['latest']['reason'])): ?>
|
||||||
|
<p>
|
||||||
|
<strong>دلیل:</strong>
|
||||||
|
<?php echo esc_html($data['review']['latest']['reason']); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($data['review']['latest']['notes'])): ?>
|
||||||
|
<p>
|
||||||
|
<strong>توضیحات:</strong>
|
||||||
|
<?php echo nl2br(esc_html($data['review']['latest']['notes'])); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($data['review']['can_resubmit'])): ?>
|
||||||
|
<a
|
||||||
|
href="<?php echo esc_url(home_url('/new-order/?resubmit=' . $order_id)); ?>"
|
||||||
|
class="ifnex-btn ifnex-btn-warning">
|
||||||
|
✏️ اصلاح و ارسال مجدد سفارش
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($data['review']['history']) && is_array($data['review']['history'])): ?>
|
||||||
|
<details class="ifnex-review-history">
|
||||||
|
<summary>تاریخچه بررسی سفارش</summary>
|
||||||
|
|
||||||
|
<div class="ifnex-review-history-list">
|
||||||
|
<?php foreach ($data['review']['history'] as $review): ?>
|
||||||
|
<div class="ifnex-review-history-item">
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
Revision <?php echo esc_html($review['revision'] ?? '—'); ?>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<span class="ifnex-badge ifnex-badge-<?php echo esc_attr($review['color'] ?? ''); ?>">
|
||||||
|
<?php echo esc_html($review['label'] ?? $review['decision'] ?? ''); ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($review['reason'])): ?>
|
||||||
|
<p><?php echo esc_html($review['reason']); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($review['notes'])): ?>
|
||||||
|
<p><?php echo nl2br(esc_html($review['notes'])); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($review['reviewed_at'])): ?>
|
||||||
|
<small><?php echo esc_html($review['reviewed_at']); ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- مسیر ارسال -->
|
<!-- مسیر ارسال -->
|
||||||
<div class="ifnex-detail-section">
|
<div class="ifnex-detail-section">
|
||||||
<h3>🗺️ مسیر ارسال</h3>
|
<h3>🗺️ مسیر ارسال</h3>
|
||||||
@ -1087,6 +1161,55 @@ function ifnex_order_detail_shortcode($atts) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($data['packages']) && is_array($data['packages'])): ?>
|
||||||
|
<div class="ifnex-detail-section">
|
||||||
|
<h3>📦 بستههای مرسوله</h3>
|
||||||
|
|
||||||
|
<div class="ifnex-packages-list">
|
||||||
|
<?php foreach ($data['packages'] as $package): ?>
|
||||||
|
<div class="ifnex-package-card">
|
||||||
|
<div class="ifnex-package-header">
|
||||||
|
<strong>بسته <?php echo esc_html($package['package_no'] ?? '—'); ?></strong>
|
||||||
|
<?php if (!empty($package['description'])): ?>
|
||||||
|
<span><?php echo esc_html($package['description']); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ifnex-info-grid">
|
||||||
|
<div class="ifnex-info-item">
|
||||||
|
<span class="ifnex-info-label">وزن</span>
|
||||||
|
<span class="ifnex-info-value">
|
||||||
|
<?php echo esc_html($package['weight'] ?? '—'); ?> kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ifnex-info-item">
|
||||||
|
<span class="ifnex-info-label">وزن حجمی</span>
|
||||||
|
<span class="ifnex-info-value">
|
||||||
|
<?php echo esc_html($package['volumetric_weight'] ?? '—'); ?> kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ifnex-info-item">
|
||||||
|
<span class="ifnex-info-label">وزن قابل محاسبه</span>
|
||||||
|
<span class="ifnex-info-value">
|
||||||
|
<?php echo esc_html($package['chargeable_weight'] ?? '—'); ?> kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ifnex-info-item">
|
||||||
|
<span class="ifnex-info-label">ابعاد</span>
|
||||||
|
<span class="ifnex-info-value">
|
||||||
|
<?php echo esc_html($package['dimensions'] ?? '—'); ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- اطلاعات مالی -->
|
<!-- اطلاعات مالی -->
|
||||||
<div class="ifnex-detail-section">
|
<div class="ifnex-detail-section">
|
||||||
<h3>💰 اطلاعات مالی</h3>
|
<h3>💰 اطلاعات مالی</h3>
|
||||||
@ -1113,46 +1236,46 @@ function ifnex_order_detail_shortcode($atts) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- اطلاعات فرستنده -->
|
<!-- اطلاعات فرستنده -->
|
||||||
<?php if (!empty($data['sender_name']) || !empty($data['sender_phone'])): ?>
|
<?php if (!empty($data['sender']['name']) || !empty($data['sender']['phone'])): ?>
|
||||||
<div class="ifnex-detail-section">
|
<div class="ifnex-detail-section">
|
||||||
<h3>👤 اطلاعات فرستنده</h3>
|
<h3>👤 اطلاعات فرستنده</h3>
|
||||||
<div class="ifnex-info-grid">
|
<div class="ifnex-info-grid">
|
||||||
<div class="ifnex-info-item">
|
<div class="ifnex-info-item">
|
||||||
<span class="ifnex-info-label">نام</span>
|
<span class="ifnex-info-label">نام</span>
|
||||||
<span class="ifnex-info-value"><?php echo esc_html($data['sender_name'] ?? '—'); ?></span>
|
<span class="ifnex-info-value"><?php echo esc_html($data['sender']['name'] ?? '—'); ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ifnex-info-item">
|
<div class="ifnex-info-item">
|
||||||
<span class="ifnex-info-label">تلفن</span>
|
<span class="ifnex-info-label">تلفن</span>
|
||||||
<span class="ifnex-info-value"><?php echo esc_html($data['sender_phone'] ?? '—'); ?></span>
|
<span class="ifnex-info-value"><?php echo esc_html($data['sender']['phone'] ?? '—'); ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ifnex-info-item full">
|
<div class="ifnex-info-item full">
|
||||||
<span class="ifnex-info-label">آدرس</span>
|
<span class="ifnex-info-label">آدرس</span>
|
||||||
<span class="ifnex-info-value"><?php echo esc_html($data['sender_address'] ?? '—'); ?></span>
|
<span class="ifnex-info-value"><?php echo esc_html($data['sender']['address'] ?? '—'); ?></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- اطلاعات گیرنده -->
|
<!-- اطلاعات گیرنده -->
|
||||||
<?php if (!empty($data['receiver_name']) || !empty($data['receiver_phone'])): ?>
|
<?php if (!empty($data['receiver']['name']) || !empty($data['receiver']['phone'])): ?>
|
||||||
<div class="ifnex-detail-section">
|
<div class="ifnex-detail-section">
|
||||||
<h3>📮 اطلاعات گیرنده</h3>
|
<h3>📮 اطلاعات گیرنده</h3>
|
||||||
<div class="ifnex-info-grid">
|
<div class="ifnex-info-grid">
|
||||||
<div class="ifnex-info-item">
|
<div class="ifnex-info-item">
|
||||||
<span class="ifnex-info-label">نام</span>
|
<span class="ifnex-info-label">نام</span>
|
||||||
<span class="ifnex-info-value"><?php echo esc_html($data['receiver_name'] ?? '—'); ?></span>
|
<span class="ifnex-info-value"><?php echo esc_html($data['receiver']['name'] ?? '—'); ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ifnex-info-item">
|
<div class="ifnex-info-item">
|
||||||
<span class="ifnex-info-label">تلفن</span>
|
<span class="ifnex-info-label">تلفن</span>
|
||||||
<span class="ifnex-info-value"><?php echo esc_html($data['receiver_phone'] ?? '—'); ?></span>
|
<span class="ifnex-info-value"><?php echo esc_html($data['receiver']['phone'] ?? '—'); ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ifnex-info-item full">
|
<div class="ifnex-info-item full">
|
||||||
<span class="ifnex-info-label">آدرس</span>
|
<span class="ifnex-info-label">آدرس</span>
|
||||||
<span class="ifnex-info-value"><?php echo esc_html($data['receiver_address'] ?? '—'); ?></span>
|
<span class="ifnex-info-value"><?php echo esc_html($data['receiver']['address'] ?? '—'); ?></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- دانلود مدارک -->
|
<!-- دانلود مدارک -->
|
||||||
<div class="ifnex-detail-section">
|
<div class="ifnex-detail-section">
|
||||||
@ -1199,12 +1322,9 @@ function ifnex_order_detail_shortcode($atts) {
|
|||||||
<div class="ifnex-timeline-dot"></div>
|
<div class="ifnex-timeline-dot"></div>
|
||||||
<div class="ifnex-timeline-content">
|
<div class="ifnex-timeline-content">
|
||||||
<div class="ifnex-timeline-header">
|
<div class="ifnex-timeline-header">
|
||||||
<strong><?php echo esc_html($event['event_description'] ?? ''); ?></strong>
|
<strong><?php echo esc_html($event['description'] ?? ''); ?></strong>
|
||||||
<span class="ifnex-timeline-date">
|
<span class="ifnex-timeline-date">
|
||||||
<?php echo esc_html($event['event_date'] ?? ''); ?>
|
<?php echo esc_html($event['date'] ?? ''); ?>
|
||||||
<?php if (!empty($event['event_time'])): ?>
|
|
||||||
- <?php echo esc_html($event['event_time']); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($event['location'])): ?>
|
<?php if (!empty($event['location'])): ?>
|
||||||
@ -1316,7 +1436,7 @@ function ifnex_handle_pdf_download($order_id, $download_type) {
|
|||||||
$api_url = get_option('ifnex_api_url', 'http://localhost:8000/api/v1');
|
$api_url = get_option('ifnex_api_url', 'http://localhost:8000/api/v1');
|
||||||
$download_url = rtrim($api_url, '/') . "/customer/orders/{$order_id}/pdf/{$download_type}";
|
$download_url = rtrim($api_url, '/') . "/customer/orders/{$order_id}/pdf/{$download_type}";
|
||||||
|
|
||||||
$token = get_user_meta($user_id, 'ifnex_api_token', true);
|
$token = get_user_meta($user_id, 'ifnex_laravel_token', true);
|
||||||
if (!$token) wp_die('توکن احراز هویت یافت نشد.');
|
if (!$token) wp_die('توکن احراز هویت یافت نشد.');
|
||||||
|
|
||||||
$response = wp_remote_get($download_url, array(
|
$response = wp_remote_get($download_url, array(
|
||||||
@ -1471,7 +1591,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
|||||||
<div class="ifnx-stat" style="--tint:#fffbeb; --clr:#f59e0b;">
|
<div class="ifnx-stat" style="--tint:#fffbeb; --clr:#f59e0b;">
|
||||||
<div class="ifnx-stat-icon"><?php echo ifnex_icon('clock', 22); ?></div>
|
<div class="ifnx-stat-icon"><?php echo ifnex_icon('clock', 22); ?></div>
|
||||||
<div class="ifnx-stat-info">
|
<div class="ifnx-stat-info">
|
||||||
<span class="ifnx-stat-value"><?php echo number_format($stats['pending_orders'] ?? 0); ?></span>
|
<span class="ifnx-stat-value"><?php echo number_format($stats['pending_payment'] ?? 0); ?></span>
|
||||||
<span class="ifnx-stat-label">در انتظار پرداخت</span>
|
<span class="ifnx-stat-label">در انتظار پرداخت</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -145,8 +145,11 @@ class IFNEX_User_Bridge {
|
|||||||
error_log("IFNEX API [{$method}] {$endpoint} => Status: {$code}");
|
error_log("IFNEX API [{$method}] {$endpoint} => Status: {$code}");
|
||||||
|
|
||||||
if ($code === 401) {
|
if ($code === 401) {
|
||||||
$this->invalidate_token(get_current_user_id());
|
$this->invalidate_token($user_id);
|
||||||
return new WP_Error('token_expired', 'توکن منقضی شده است. لطفاً دوباره وارد شوید.');
|
return new WP_Error(
|
||||||
|
'token_expired',
|
||||||
|
'توکن منقضی شده است. لطفاً دوباره وارد شوید.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// اگر پاسخ JSON نیست
|
// اگر پاسخ JSON نیست
|
||||||
@ -319,6 +322,24 @@ class IFNEX_User_Bridge {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ارسال مجدد سفارش پس از درخواست اصلاح
|
||||||
|
*/
|
||||||
|
public function resubmit_customer_order($user_id, $shipment_id, $order_data) {
|
||||||
|
$result = $this->authenticated_request(
|
||||||
|
$user_id,
|
||||||
|
"/customer/orders/{$shipment_id}/resubmit",
|
||||||
|
'POST',
|
||||||
|
$order_data
|
||||||
|
);
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,13 +380,11 @@ class IFNEX_User_Bridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// AJAX Handler برای لغو سفارش
|
// AJAX Handler برای لغو سفارش
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
add_action('wp_ajax_ifnex_cancel_order', 'ifnex_cancel_order_ajax');
|
add_action('wp_ajax_ifnex_cancel_order', 'ifnex_cancel_order_ajax');
|
||||||
|
|
||||||
function ifnex_cancel_order_ajax() {
|
function ifnex_cancel_order_ajax() {
|
||||||
|
|
||||||
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
||||||
|
|
||||||
if (!is_user_logged_in()) {
|
if (!is_user_logged_in()) {
|
||||||
@ -373,12 +392,17 @@ function ifnex_cancel_order_ajax() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$order_id = intval($_POST['order_id'] ?? 0);
|
$order_id = intval($_POST['order_id'] ?? 0);
|
||||||
|
|
||||||
if (!$order_id) {
|
if (!$order_id) {
|
||||||
wp_send_json_error('شناسه سفارش نامعتبر است.');
|
wp_send_json_error('شناسه سفارش نامعتبر است.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$bridge = new IFNEX_User_Bridge();
|
$bridge = new IFNEX_User_Bridge();
|
||||||
$result = $bridge->cancel_customer_order(get_current_user_id(), $order_id);
|
|
||||||
|
$result = $bridge->cancel_customer_order(
|
||||||
|
get_current_user_id(),
|
||||||
|
$order_id
|
||||||
|
);
|
||||||
|
|
||||||
if (is_wp_error($result)) {
|
if (is_wp_error($result)) {
|
||||||
wp_send_json_error($result->get_error_message());
|
wp_send_json_error($result->get_error_message());
|
||||||
@ -387,6 +411,146 @@ function ifnex_cancel_order_ajax() {
|
|||||||
wp_send_json_success('سفارش لغو شد.');
|
wp_send_json_success('سفارش لغو شد.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// AJAX Handler برای دریافت سفارش جهت اصلاح و ارسال مجدد
|
||||||
|
add_action(
|
||||||
|
'wp_ajax_ifnex_get_resubmit_order',
|
||||||
|
'ifnex_get_resubmit_order_ajax'
|
||||||
|
);
|
||||||
|
|
||||||
|
function ifnex_get_resubmit_order_ajax() {
|
||||||
|
|
||||||
|
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
||||||
|
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
wp_send_json_error('برای اصلاح سفارش باید وارد حساب خود شوید.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$order_id = absint($_POST['order_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$order_id) {
|
||||||
|
wp_send_json_error('شناسه سفارش نامعتبر است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
|
||||||
|
$bridge = new IFNEX_User_Bridge();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* سفارش از طریق endpoint احرازشده مشتری دریافت میشود.
|
||||||
|
* بنابراین مالکیت سفارش نیز در Laravel بررسی میشود.
|
||||||
|
*/
|
||||||
|
$result = $bridge->get_customer_order(
|
||||||
|
$user_id,
|
||||||
|
$order_id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
wp_send_json_error($result->get_error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($result['data']) || !is_array($result['data'])) {
|
||||||
|
wp_send_json_error('اطلاعات سفارش یافت نشد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = $result['data'];
|
||||||
|
$review = $order['review'] ?? [];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* فقط سفارشی که آخرین تصمیم آن changes_requested است
|
||||||
|
* قابلیت اصلاح و ارسال مجدد دارد.
|
||||||
|
*/
|
||||||
|
if (($review['state'] ?? '') !== 'changes_requested') {
|
||||||
|
wp_send_json_error(
|
||||||
|
'این سفارش در حال حاضر قابل اصلاح و ارسال مجدد نیست.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($review['can_resubmit'])) {
|
||||||
|
wp_send_json_error(
|
||||||
|
'امکان ارسال مجدد این سفارش فعال نیست.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order_id = $order['id'] ?? $order_id;
|
||||||
|
|
||||||
|
wp_send_json_success([
|
||||||
|
'id' => $order_id,
|
||||||
|
|
||||||
|
'direction' => $order['direction'] ?? '',
|
||||||
|
'type' => $order['type'] ?? '',
|
||||||
|
|
||||||
|
'from_country' => $order['from_country'] ?? null,
|
||||||
|
'to_country' => $order['to_country'] ?? null,
|
||||||
|
|
||||||
|
'sender' => $order['sender'] ?? null,
|
||||||
|
'receiver' => $order['receiver'] ?? null,
|
||||||
|
|
||||||
|
'packages' => $order['packages'] ?? [],
|
||||||
|
'items' => $order['items'] ?? [],
|
||||||
|
|
||||||
|
'review' => $review,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AJAX Handler برای ارسال مجدد سفارش
|
||||||
|
add_action('wp_ajax_ifnex_resubmit_order', 'ifnex_resubmit_order_ajax');
|
||||||
|
|
||||||
|
function ifnex_resubmit_order_ajax() {
|
||||||
|
|
||||||
|
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
||||||
|
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
wp_send_json_error('برای ارسال مجدد سفارش باید وارد حساب خود شوید.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$order_id = absint($_POST['order_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$order_id) {
|
||||||
|
wp_send_json_error('شناسه سفارش نامعتبر است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $_POST['data'] ?? [];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* بسته به نحوه ارسال jQuery ممکن است data
|
||||||
|
* به صورت array یا JSON string دریافت شود.
|
||||||
|
*/
|
||||||
|
if (is_string($data)) {
|
||||||
|
$decoded = json_decode(wp_unslash($data), true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
wp_send_json_error('دادههای سفارش نامعتبر هستند.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data) || empty($data)) {
|
||||||
|
wp_send_json_error('دادههای سفارش ارسال نشده است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bridge = new IFNEX_User_Bridge();
|
||||||
|
|
||||||
|
$result = $bridge->resubmit_customer_order(
|
||||||
|
get_current_user_id(),
|
||||||
|
$order_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 AJAX script
|
// Enqueue AJAX script
|
||||||
add_action('wp_enqueue_scripts', 'ifnex_enqueue_ajax_script');
|
add_action('wp_enqueue_scripts', 'ifnex_enqueue_ajax_script');
|
||||||
|
|
||||||
|
|||||||
56
04_Laravel/app/Enums/ReviewState.php
Normal file
56
04_Laravel/app/Enums/ReviewState.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ReviewState: string
|
||||||
|
{
|
||||||
|
case Pending = 'pending';
|
||||||
|
case ChangesRequested = 'changes_requested';
|
||||||
|
case Approved = 'approved';
|
||||||
|
case Rejected = 'rejected';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Pending => 'در انتظار بررسی',
|
||||||
|
self::ChangesRequested => 'نیازمند اصلاح',
|
||||||
|
self::Approved => 'تأیید شده',
|
||||||
|
self::Rejected => 'رد شده',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Pending => 'warning',
|
||||||
|
self::ChangesRequested => 'warning',
|
||||||
|
self::Approved => 'success',
|
||||||
|
self::Rejected => 'danger',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isChangesRequested(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ChangesRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isApproved(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRejected(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canBeResubmitted(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ChangesRequested;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,9 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Enums\ShipmentStatus;
|
use App\Enums\ShipmentStatus;
|
||||||
|
use App\Enums\ReviewState;
|
||||||
|
use App\Services\ShipmentReviewService;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use App\Filament\Resources\ShipmentResource\Pages;
|
use App\Filament\Resources\ShipmentResource\Pages;
|
||||||
use App\Filament\Resources\ShipmentResource\RelationManagers;
|
use App\Filament\Resources\ShipmentResource\RelationManagers;
|
||||||
use App\Models\Shipment;
|
use App\Models\Shipment;
|
||||||
@ -53,7 +56,12 @@ class ShipmentResource extends Resource
|
|||||||
$case->value => $case->label(),
|
$case->value => $case->label(),
|
||||||
]))
|
]))
|
||||||
->default('processed')
|
->default('processed')
|
||||||
->required(),
|
->required()
|
||||||
|
->disabled(fn (?Shipment $record): bool =>
|
||||||
|
$record !== null &&
|
||||||
|
$record->review_state !== ReviewState::Approved
|
||||||
|
)
|
||||||
|
->dehydrated(),
|
||||||
Forms\Components\TextInput::make('reason_for_export')
|
Forms\Components\TextInput::make('reason_for_export')
|
||||||
->nullable()
|
->nullable()
|
||||||
->label('Reason for Export'),
|
->label('Reason for Export'),
|
||||||
@ -390,6 +398,13 @@ class ShipmentResource extends Resource
|
|||||||
->color(fn (ShipmentStatus $state): string => $state->color())
|
->color(fn (ShipmentStatus $state): string => $state->color())
|
||||||
->formatStateUsing(fn (ShipmentStatus $state): string => $state->label())
|
->formatStateUsing(fn (ShipmentStatus $state): string => $state->label())
|
||||||
->searchable()->label('Status'),
|
->searchable()->label('Status'),
|
||||||
|
Tables\Columns\TextColumn::make('review_state')
|
||||||
|
->badge()
|
||||||
|
->color(fn (ReviewState $state): string => $state->color())
|
||||||
|
->formatStateUsing(fn (ReviewState $state): string => $state->label())
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->label('Review'),
|
||||||
Tables\Columns\TextColumn::make('sender_name')
|
Tables\Columns\TextColumn::make('sender_name')
|
||||||
->searchable()->toggleable()->label('Sender'),
|
->searchable()->toggleable()->label('Sender'),
|
||||||
Tables\Columns\TextColumn::make('receiver_name')
|
Tables\Columns\TextColumn::make('receiver_name')
|
||||||
@ -449,33 +464,162 @@ class ShipmentResource extends Resource
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\ViewAction::make(),
|
Tables\Actions\ViewAction::make(),
|
||||||
Tables\Actions\EditAction::make(),
|
|
||||||
Action::make('approve')
|
|
||||||
->label('تأیید')
|
|
||||||
->icon('heroicon-o-check-circle')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('تأیید سفارش')
|
|
||||||
->modalDescription('آیا از تأیید این سفارش اطمینان دارید؟ پس از تأیید، مشتری میتواند پرداخت را انجام دهد.')
|
|
||||||
->modalSubmitActionLabel('بله، تأیید کن')
|
|
||||||
->visible(fn ($record) => $record->status === \App\Enums\ShipmentStatus::PendingApproval)
|
|
||||||
->action(function ($record) {
|
|
||||||
$oldStatus = $record->status;
|
|
||||||
$record->update(['status' => \App\Enums\ShipmentStatus::Approved]);
|
|
||||||
|
|
||||||
\App\Models\ShipmentStatusHistory::create([
|
Tables\Actions\EditAction::make(),
|
||||||
'shipment_id' => $record->id,
|
|
||||||
'from_status' => $oldStatus->value,
|
|
||||||
'to_status' => \App\Enums\ShipmentStatus::Approved->value,
|
|
||||||
'reason' => 'تأیید توسط مدیر از پنل Filament',
|
|
||||||
'changed_by' => auth()->id(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
\Filament\Notifications\Notification::make()
|
Action::make('approve')
|
||||||
->title('سفارش با موفقیت تأیید شد')
|
->label('تأیید')
|
||||||
->success()
|
->icon('heroicon-o-check-circle')
|
||||||
->send();
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('تأیید سفارش')
|
||||||
|
->modalDescription('آیا از تأیید این سفارش اطمینان دارید؟ پس از تأیید، سفارش وارد مرحله پرداخت خواهد شد.')
|
||||||
|
->modalSubmitActionLabel('بله، تأیید کن')
|
||||||
|
->visible(fn (Shipment $record): bool =>
|
||||||
|
$record->status === ShipmentStatus::PendingApproval &&
|
||||||
|
$record->review_state === ReviewState::Pending
|
||||||
|
)
|
||||||
|
->form([
|
||||||
|
Forms\Components\Textarea::make('notes')
|
||||||
|
->label('یادداشت بررسی')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(1000)
|
||||||
|
->nullable(),
|
||||||
|
])
|
||||||
|
->action(function (Shipment $record, array $data): void {
|
||||||
|
try {
|
||||||
|
app(ShipmentReviewService::class)->approve(
|
||||||
|
$record,
|
||||||
|
auth()->user(),
|
||||||
|
$data['notes'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('سفارش با موفقیت تأیید شد.')
|
||||||
|
->body('مشتری اکنون میتواند پرداخت سفارش را انجام دهد.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('خطا در تأیید سفارش.')
|
||||||
|
->body('عملیات انجام نشد. لاگ سیستم را بررسی کنید.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('requestChanges')
|
||||||
|
->label('درخواست اصلاح')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (Shipment $record): bool =>
|
||||||
|
$record->status === ShipmentStatus::PendingApproval &&
|
||||||
|
$record->review_state === ReviewState::Pending
|
||||||
|
)
|
||||||
|
->modalHeading('درخواست اصلاح سفارش')
|
||||||
|
->modalDescription('دلیل اصلاح باید برای مشتری قابل فهم و مشخص باشد.')
|
||||||
|
->modalSubmitActionLabel('ثبت درخواست اصلاح')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Textarea::make('reason')
|
||||||
|
->label('دلیل اصلاح')
|
||||||
|
->required()
|
||||||
|
->rows(4)
|
||||||
|
->maxLength(1000),
|
||||||
|
|
||||||
|
Forms\Components\Textarea::make('notes')
|
||||||
|
->label('توضیحات تکمیلی')
|
||||||
|
->rows(4)
|
||||||
|
->maxLength(1000)
|
||||||
|
->nullable(),
|
||||||
|
])
|
||||||
|
->action(function (Shipment $record, array $data): void {
|
||||||
|
try {
|
||||||
|
app(ShipmentReviewService::class)->requestChanges(
|
||||||
|
$record,
|
||||||
|
auth()->user(),
|
||||||
|
$data['reason'],
|
||||||
|
$data['notes'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('درخواست اصلاح ثبت شد.')
|
||||||
|
->body('سفارش اکنون منتظر اصلاح و ارسال مجدد مشتری است.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('خطا در ثبت درخواست اصلاح.')
|
||||||
|
->body('عملیات انجام نشد. لاگ سیستم را بررسی کنید.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('reject')
|
||||||
|
->label('رد سفارش')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (Shipment $record): bool =>
|
||||||
|
$record->status === ShipmentStatus::PendingApproval &&
|
||||||
|
$record->review_state === ReviewState::Pending
|
||||||
|
)
|
||||||
|
->modalHeading('رد سفارش')
|
||||||
|
->modalDescription('رد سفارش به معنی پایان این چرخه بررسی است و مشتری نمیتواند همان سفارش را مجدداً ارسال کند.')
|
||||||
|
->modalSubmitActionLabel('رد سفارش')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Textarea::make('reason')
|
||||||
|
->label('دلیل رد')
|
||||||
|
->required()
|
||||||
|
->rows(4)
|
||||||
|
->maxLength(1000),
|
||||||
|
])
|
||||||
|
->action(function (Shipment $record, array $data): void {
|
||||||
|
try {
|
||||||
|
app(ShipmentReviewService::class)->reject(
|
||||||
|
$record,
|
||||||
|
auth()->user(),
|
||||||
|
$data['reason']
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('سفارش رد شد.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('خطا در رد سفارش.')
|
||||||
|
->body('عملیات انجام نشد. لاگ سیستم را بررسی کنید.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
@ -550,42 +694,6 @@ class ShipmentResource extends Resource
|
|||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
Tables\Actions\BulkActionGroup::make([
|
||||||
Tables\Actions\DeleteBulkAction::make(),
|
Tables\Actions\DeleteBulkAction::make(),
|
||||||
|
|
||||||
Action::make('changeStatus')
|
|
||||||
->label('تغییر وضعیت')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->form([
|
|
||||||
Forms\Components\Select::make('new_status')
|
|
||||||
->label('وضعیت جدید')
|
|
||||||
->required()
|
|
||||||
->options(collect(ShipmentStatus::cases())->mapWithKeys(fn ($case) => [
|
|
||||||
$case->value => $case->label(),
|
|
||||||
])),
|
|
||||||
Forms\Components\Textarea::make('reason')
|
|
||||||
->label('دلیل تغییر')
|
|
||||||
->rows(2)
|
|
||||||
->maxLength(500),
|
|
||||||
])
|
|
||||||
->action(function (array $data, \Illuminate\Support\Collection $records) {
|
|
||||||
foreach ($records as $shipment) {
|
|
||||||
\App\Models\ShipmentStatusHistory::create([
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
'from_status' => $shipment->status?->value,
|
|
||||||
'to_status' => $data['new_status'],
|
|
||||||
'reason' => $data['reason'] ?? null,
|
|
||||||
'changed_by' => auth()->id(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$shipment->update(['status' => $data['new_status']]);
|
|
||||||
}
|
|
||||||
|
|
||||||
\Filament\Notifications\Notification::make()
|
|
||||||
->title(count($records) . ' محموله بروزرسانی شد')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ use App\Models\ShipmentItem;
|
|||||||
use App\Models\ShipmentPackage;
|
use App\Models\ShipmentPackage;
|
||||||
use App\Models\Country;
|
use App\Models\Country;
|
||||||
use App\Enums\ShipmentStatus;
|
use App\Enums\ShipmentStatus;
|
||||||
|
use App\Enums\ReviewState;
|
||||||
|
use App\Models\ShipmentReview;
|
||||||
use App\Enums\ShipmentDirection;
|
use App\Enums\ShipmentDirection;
|
||||||
use App\Enums\ShipmentType;
|
use App\Enums\ShipmentType;
|
||||||
use App\Services\PriceCalculatorService;
|
use App\Services\PriceCalculatorService;
|
||||||
@ -301,7 +303,9 @@ class CustomerOrderController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$this->calculator->consumeDiscountCode(
|
||||||
|
$priceResult['discount_code'] ?? null
|
||||||
|
);
|
||||||
return $shipment;
|
return $shipment;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -336,7 +340,14 @@ class CustomerOrderController extends Controller
|
|||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$shipment->load(['fromCountry', 'toCountry', 'items', 'trackingEvents']);
|
$shipment->load([
|
||||||
|
'fromCountry',
|
||||||
|
'toCountry',
|
||||||
|
'packages',
|
||||||
|
'items',
|
||||||
|
'trackingEvents',
|
||||||
|
'reviews',
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@ -344,6 +355,321 @@ class CustomerOrderController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ارسال مجدد سفارش پس از درخواست اصلاح
|
||||||
|
* POST /api/v1/customer/orders/{shipment}/resubmit
|
||||||
|
*/
|
||||||
|
public function resubmit(Shipment $shipment, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($shipment->user_id !== $user->id) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'شما به این سفارش دسترسی ندارید.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$shipment->status !== ShipmentStatus::PendingApproval ||
|
||||||
|
$shipment->review_state !== ReviewState::ChangesRequested
|
||||||
|
) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'این سفارش در وضعیت قابل ارسال مجدد نیست.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' => ['nullable', 'numeric', 'min:0.1'],
|
||||||
|
'volumetric_weight' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'dimensions' => ['nullable', 'string', 'max:100'],
|
||||||
|
|
||||||
|
// بستهها
|
||||||
|
'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'],
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->wallet || $user->wallet->is_frozen) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'کیف پول شما غیرفعال است. لطفاً با پشتیبانی تماس بگیرید.',
|
||||||
|
], 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['weight'] = $totalWeight;
|
||||||
|
$validated['volumetric_weight'] = $totalVolumetricWeight;
|
||||||
|
$validated['chargeable_weight'] = max(
|
||||||
|
$totalWeight,
|
||||||
|
$totalVolumetricWeight
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$validated['chargeable_weight'] = max(
|
||||||
|
(float) ($validated['weight'] ?? 0),
|
||||||
|
(float) ($validated['volumetric_weight'] ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = DB::transaction(function () use (
|
||||||
|
$shipment,
|
||||||
|
$validated,
|
||||||
|
$priceResult,
|
||||||
|
$user
|
||||||
|
) {
|
||||||
|
$shipment = Shipment::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($shipment->id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$shipment->user_id !== $user->id ||
|
||||||
|
$shipment->status !== ShipmentStatus::PendingApproval ||
|
||||||
|
$shipment->review_state !== ReviewState::ChangesRequested
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$revisionNo = ((int) $shipment->reviews()->max('revision_no')) + 1;
|
||||||
|
|
||||||
|
$shipment->update([
|
||||||
|
'direction' => $validated['direction'],
|
||||||
|
'type' => $validated['type'],
|
||||||
|
'status' => ShipmentStatus::PendingApproval,
|
||||||
|
'review_state' => ReviewState::Pending,
|
||||||
|
|
||||||
|
'weight' => $validated['weight'],
|
||||||
|
'volumetric_weight' => $validated['volumetric_weight'] ?? null,
|
||||||
|
'chargeable_weight' => $validated['chargeable_weight'],
|
||||||
|
'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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shipment->packages()->delete();
|
||||||
|
$shipment->items()->delete();
|
||||||
|
|
||||||
|
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_no' => $index + 1,
|
||||||
|
'weight' => $pkg['weight'],
|
||||||
|
'volumetric_weight' => $volWeight,
|
||||||
|
'chargeable_weight' => $chargeable,
|
||||||
|
'dimensions' => $pkg['dimensions'] ?? null,
|
||||||
|
'content_description' => $pkg['description'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ShipmentPackage::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'package_no' => 1,
|
||||||
|
'weight' => $validated['weight'],
|
||||||
|
'volumetric_weight' => $validated['volumetric_weight'] ?? 0,
|
||||||
|
'chargeable_weight' => $validated['chargeable_weight'],
|
||||||
|
'dimensions' => $validated['dimensions'] ?? null,
|
||||||
|
'content_description' => 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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->calculator->consumeDiscountCode(
|
||||||
|
$priceResult['discount_code'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
ShipmentReview::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'revision_no' => $revisionNo,
|
||||||
|
'decision' => ReviewState::Pending->value,
|
||||||
|
'reason' => null,
|
||||||
|
'notes' => null,
|
||||||
|
'reviewed_by' => null,
|
||||||
|
'reviewed_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $shipment;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'این سفارش دیگر در وضعیت قابل ارسال مجدد نیست.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'سفارش با موفقیت اصلاح و مجدداً برای بررسی ارسال شد.',
|
||||||
|
'data' => $this->formatShipment(
|
||||||
|
$result->fresh()->load([
|
||||||
|
'fromCountry',
|
||||||
|
'toCountry',
|
||||||
|
'items',
|
||||||
|
])
|
||||||
|
),
|
||||||
|
'pricing' => $priceResult,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در ارسال مجدد سفارش: ' . $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* لغو سفارش (فقط در وضعیت pending_payment)
|
* لغو سفارش (فقط در وضعیت pending_payment)
|
||||||
* POST /api/v1/customer/orders/{shipment}/cancel
|
* POST /api/v1/customer/orders/{shipment}/cancel
|
||||||
@ -467,6 +793,7 @@ class CustomerOrderController extends Controller
|
|||||||
'awb_no' => $shipment->awb_no,
|
'awb_no' => $shipment->awb_no,
|
||||||
'direction' => $shipment->direction?->value,
|
'direction' => $shipment->direction?->value,
|
||||||
'type' => $shipment->type?->value,
|
'type' => $shipment->type?->value,
|
||||||
|
|
||||||
'status' => [
|
'status' => [
|
||||||
'value' => $shipment->status?->value,
|
'value' => $shipment->status?->value,
|
||||||
'label' => $shipment->status?->label(),
|
'label' => $shipment->status?->label(),
|
||||||
@ -476,16 +803,25 @@ class CustomerOrderController extends Controller
|
|||||||
'can_pay' => $shipment->status?->canBePaid(),
|
'can_pay' => $shipment->status?->canBePaid(),
|
||||||
'is_approved' => $shipment->status?->isApproved(),
|
'is_approved' => $shipment->status?->isApproved(),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'review' => [
|
||||||
|
'state' => $shipment->review_state?->value,
|
||||||
|
'label' => $shipment->review_state?->label(),
|
||||||
|
'color' => $shipment->review_state?->color(),
|
||||||
|
],
|
||||||
|
|
||||||
'from_country' => $shipment->fromCountry ? [
|
'from_country' => $shipment->fromCountry ? [
|
||||||
'id' => $shipment->fromCountry->id,
|
'id' => $shipment->fromCountry->id,
|
||||||
'name' => $shipment->fromCountry->name,
|
'name' => $shipment->fromCountry->name,
|
||||||
'iso_code' => $shipment->fromCountry->iso_code,
|
'iso_code' => $shipment->fromCountry->iso_code,
|
||||||
] : null,
|
] : null,
|
||||||
|
|
||||||
'to_country' => $shipment->toCountry ? [
|
'to_country' => $shipment->toCountry ? [
|
||||||
'id' => $shipment->toCountry->id,
|
'id' => $shipment->toCountry->id,
|
||||||
'name' => $shipment->toCountry->name,
|
'name' => $shipment->toCountry->name,
|
||||||
'iso_code' => $shipment->toCountry->iso_code,
|
'iso_code' => $shipment->toCountry->iso_code,
|
||||||
] : null,
|
] : null,
|
||||||
|
|
||||||
'weight' => $shipment->weight,
|
'weight' => $shipment->weight,
|
||||||
'chargeable_weight' => $shipment->chargeable_weight,
|
'chargeable_weight' => $shipment->chargeable_weight,
|
||||||
'total_fee' => $shipment->total_fee,
|
'total_fee' => $shipment->total_fee,
|
||||||
@ -496,6 +832,38 @@ class CustomerOrderController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($detailed) {
|
if ($detailed) {
|
||||||
|
$reviews = $shipment->reviews->sortByDesc('revision_no')->values();
|
||||||
|
$latestReview = $reviews->first();
|
||||||
|
|
||||||
|
$data['review']['can_resubmit'] = $shipment->review_state?->canBeResubmitted() ?? false;
|
||||||
|
|
||||||
|
$data['review']['latest'] = $latestReview ? (function () use ($latestReview) {
|
||||||
|
$decision = ReviewState::tryFrom($latestReview->decision);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'revision' => $latestReview->revision_no,
|
||||||
|
'decision' => $latestReview->decision,
|
||||||
|
'label' => $decision?->label(),
|
||||||
|
'color' => $decision?->color(),
|
||||||
|
'reason' => $latestReview->reason,
|
||||||
|
'notes' => $latestReview->notes,
|
||||||
|
'reviewed_at' => $latestReview->reviewed_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
})() : null;
|
||||||
|
|
||||||
|
$data['review']['history'] = $reviews->map(function ($review) {
|
||||||
|
$decision = ReviewState::tryFrom($review->decision);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'revision' => $review->revision_no,
|
||||||
|
'decision' => $review->decision,
|
||||||
|
'label' => $decision?->label(),
|
||||||
|
'color' => $decision?->color(),
|
||||||
|
'reason' => $review->reason,
|
||||||
|
'notes' => $review->notes,
|
||||||
|
'reviewed_at' => $review->reviewed_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
})->values()->all();
|
||||||
$data['sender'] = [
|
$data['sender'] = [
|
||||||
'name' => $shipment->sender_name,
|
'name' => $shipment->sender_name,
|
||||||
'company' => $shipment->sender_company,
|
'company' => $shipment->sender_company,
|
||||||
@ -504,6 +872,7 @@ class CustomerOrderController extends Controller
|
|||||||
'address' => $shipment->sender_address,
|
'address' => $shipment->sender_address,
|
||||||
'city' => $shipment->sender_city,
|
'city' => $shipment->sender_city,
|
||||||
];
|
];
|
||||||
|
|
||||||
$data['receiver'] = [
|
$data['receiver'] = [
|
||||||
'name' => $shipment->receiver_name,
|
'name' => $shipment->receiver_name,
|
||||||
'company' => $shipment->receiver_company,
|
'company' => $shipment->receiver_company,
|
||||||
@ -512,6 +881,17 @@ class CustomerOrderController extends Controller
|
|||||||
'address' => $shipment->receiver_address,
|
'address' => $shipment->receiver_address,
|
||||||
'city' => $shipment->receiver_city,
|
'city' => $shipment->receiver_city,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$data['packages'] = $shipment->packages->map(fn($package) => [
|
||||||
|
'package_no' => $package->package_no,
|
||||||
|
'weight' => $package->weight,
|
||||||
|
'volumetric_weight' => $package->volumetric_weight,
|
||||||
|
'chargeable_weight' => $package->chargeable_weight,
|
||||||
|
'dimensions' => $package->dimensions,
|
||||||
|
'description' => $package->content_description,
|
||||||
|
'declared_value' => $package->declared_value,
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
$data['financial'] = [
|
$data['financial'] = [
|
||||||
'shipping_price' => $shipment->shipping_price,
|
'shipping_price' => $shipment->shipping_price,
|
||||||
'extra_service' => $shipment->extra_service,
|
'extra_service' => $shipment->extra_service,
|
||||||
@ -523,6 +903,7 @@ class CustomerOrderController extends Controller
|
|||||||
'discount' => $shipment->discount,
|
'discount' => $shipment->discount,
|
||||||
'total_fee' => $shipment->total_fee,
|
'total_fee' => $shipment->total_fee,
|
||||||
];
|
];
|
||||||
|
|
||||||
$data['items'] = $shipment->items->map(fn($item) => [
|
$data['items'] = $shipment->items->map(fn($item) => [
|
||||||
'row' => $item->row_number,
|
'row' => $item->row_number,
|
||||||
'description' => $item->description,
|
'description' => $item->description,
|
||||||
@ -531,6 +912,7 @@ class CustomerOrderController extends Controller
|
|||||||
'unit_price' => $item->unit_price,
|
'unit_price' => $item->unit_price,
|
||||||
'total' => $item->total_usd,
|
'total' => $item->total_usd,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$data['tracking_events'] = $shipment->trackingEvents->map(fn($event) => [
|
$data['tracking_events'] = $shipment->trackingEvents->map(fn($event) => [
|
||||||
'date' => $event->event_date,
|
'date' => $event->event_date,
|
||||||
'description' => $event->event_description,
|
'description' => $event->event_description,
|
||||||
|
|||||||
@ -4,12 +4,11 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Shipment;
|
use App\Models\Shipment;
|
||||||
use App\Models\ShipmentStatusHistory;
|
use App\Models\ShipmentReview;
|
||||||
use App\Enums\ShipmentStatus;
|
use App\Services\ShipmentReviewService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class StaffOrderController extends Controller
|
class StaffOrderController extends Controller
|
||||||
{
|
{
|
||||||
@ -43,54 +42,88 @@ class StaffOrderController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* درخواست اصلاح سفارش توسط کارمند
|
||||||
|
* POST /api/v1/staff/orders/{shipment}/request-changes
|
||||||
|
*/
|
||||||
|
public function requestChanges(
|
||||||
|
Shipment $shipment,
|
||||||
|
Request $request,
|
||||||
|
ShipmentReviewService $reviewService
|
||||||
|
): JsonResponse {
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'reason' => ['required', 'string', 'max:1000'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$shipment = $reviewService->requestChanges(
|
||||||
|
$shipment,
|
||||||
|
$user,
|
||||||
|
$validated['reason'],
|
||||||
|
$validated['notes'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'اصلاحات موردنیاز برای سفارش ثبت شد.',
|
||||||
|
'data' => $this->formatShipment(
|
||||||
|
$shipment->load(['fromCountry', 'toCountry'])
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], 400);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در ثبت درخواست اصلاح سفارش.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تأیید سفارش توسط کارمند
|
* تأیید سفارش توسط کارمند
|
||||||
* POST /api/v1/staff/orders/{shipment}/approve
|
* POST /api/v1/staff/orders/{shipment}/approve
|
||||||
*/
|
*/
|
||||||
public function approve(Shipment $shipment, Request $request): JsonResponse
|
public function approve(
|
||||||
{
|
Shipment $shipment,
|
||||||
|
Request $request,
|
||||||
|
ShipmentReviewService $reviewService
|
||||||
|
): JsonResponse {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// بررسی وضعیت
|
|
||||||
if ($shipment->status !== ShipmentStatus::PendingApproval) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'این سفارش در وضعیت قابل تأیید نیست.',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'notes' => ['nullable', 'string', 'max:1000'],
|
'notes' => ['nullable', 'string', 'max:1000'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($shipment, $user, $validated) {
|
$shipment = $reviewService->approve(
|
||||||
// تغییر وضعیت به Approved
|
$shipment,
|
||||||
$oldStatus = $shipment->status;
|
$user,
|
||||||
$shipment->update([
|
$validated['notes'] ?? null
|
||||||
'status' => ShipmentStatus::Approved,
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
// ثبت در تاریخچه تغییرات
|
|
||||||
ShipmentStatusHistory::create([
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
'from_status' => $oldStatus->value,
|
|
||||||
'to_status' => ShipmentStatus::Approved->value,
|
|
||||||
'reason' => $validated['notes'] ?? 'تأیید توسط کارمند',
|
|
||||||
'changed_by' => $user->id,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'سفارش با موفقیت تأیید شد. مشتری میتواند پرداخت را انجام دهد.',
|
'message' => 'سفارش با موفقیت تأیید شد. مشتری میتواند پرداخت را انجام دهد.',
|
||||||
'data' => $this->formatShipment($shipment->fresh()->load(['fromCountry', 'toCountry'])),
|
'data' => $this->formatShipment(
|
||||||
|
$shipment->load(['fromCountry', 'toCountry'])
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
} catch (\Exception $e) {
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'خطا در تأیید سفارش: ' . $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
|
], 400);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در تأیید سفارش.',
|
||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -99,54 +132,45 @@ class StaffOrderController extends Controller
|
|||||||
* رد سفارش توسط کارمند
|
* رد سفارش توسط کارمند
|
||||||
* POST /api/v1/staff/orders/{shipment}/reject
|
* POST /api/v1/staff/orders/{shipment}/reject
|
||||||
*/
|
*/
|
||||||
public function reject(Shipment $shipment, Request $request): JsonResponse
|
public function reject(
|
||||||
{
|
Shipment $shipment,
|
||||||
|
Request $request,
|
||||||
|
ShipmentReviewService $reviewService
|
||||||
|
): JsonResponse {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// بررسی وضعیت
|
|
||||||
if ($shipment->status !== ShipmentStatus::PendingApproval) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'این سفارش در وضعیت قابل رد نیست.',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'reason' => ['required', 'string', 'max:1000'],
|
'reason' => ['required', 'string', 'max:1000'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($shipment, $user, $validated) {
|
$shipment = $reviewService->reject(
|
||||||
// تغییر وضعیت به Cancelled
|
$shipment,
|
||||||
$oldStatus = $shipment->status;
|
$user,
|
||||||
$shipment->update([
|
$validated['reason']
|
||||||
'status' => ShipmentStatus::Cancelled,
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
// ثبت در تاریخچه تغییرات
|
|
||||||
ShipmentStatusHistory::create([
|
|
||||||
'shipment_id' => $shipment->id,
|
|
||||||
'from_status' => $oldStatus->value,
|
|
||||||
'to_status' => ShipmentStatus::Cancelled->value,
|
|
||||||
'reason' => 'رد شده: ' . $validated['reason'],
|
|
||||||
'changed_by' => $user->id,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'سفارش رد شد.',
|
'message' => 'سفارش رد شد.',
|
||||||
'data' => $this->formatShipment($shipment->fresh()->load(['fromCountry', 'toCountry'])),
|
'data' => $this->formatShipment(
|
||||||
|
$shipment->load(['fromCountry', 'toCountry'])
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
} catch (\Exception $e) {
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'خطا در رد سفارش: ' . $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
|
], 400);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در رد سفارش.',
|
||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* فرمت کردن Shipment برای خروجی API
|
* فرمت کردن Shipment برای خروجی API
|
||||||
*/
|
*/
|
||||||
@ -157,26 +181,37 @@ class StaffOrderController extends Controller
|
|||||||
'awb_no' => $shipment->awb_no,
|
'awb_no' => $shipment->awb_no,
|
||||||
'direction' => $shipment->direction?->value,
|
'direction' => $shipment->direction?->value,
|
||||||
'type' => $shipment->type?->value,
|
'type' => $shipment->type?->value,
|
||||||
|
|
||||||
'status' => [
|
'status' => [
|
||||||
'value' => $shipment->status?->value,
|
'value' => $shipment->status?->value,
|
||||||
'label' => $shipment->status?->label(),
|
'label' => $shipment->status?->label(),
|
||||||
'color' => $shipment->status?->color(),
|
'color' => $shipment->status?->color(),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'review' => [
|
||||||
|
'state' => $shipment->review_state?->value,
|
||||||
|
'label' => $shipment->review_state?->label(),
|
||||||
|
'color' => $shipment->review_state?->color(),
|
||||||
|
],
|
||||||
|
|
||||||
'from_country' => $shipment->fromCountry ? [
|
'from_country' => $shipment->fromCountry ? [
|
||||||
'id' => $shipment->fromCountry->id,
|
'id' => $shipment->fromCountry->id,
|
||||||
'name' => $shipment->fromCountry->name,
|
'name' => $shipment->fromCountry->name,
|
||||||
'iso_code' => $shipment->fromCountry->iso_code,
|
'iso_code' => $shipment->fromCountry->iso_code,
|
||||||
] : null,
|
] : null,
|
||||||
|
|
||||||
'to_country' => $shipment->toCountry ? [
|
'to_country' => $shipment->toCountry ? [
|
||||||
'id' => $shipment->toCountry->id,
|
'id' => $shipment->toCountry->id,
|
||||||
'name' => $shipment->toCountry->name,
|
'name' => $shipment->toCountry->name,
|
||||||
'iso_code' => $shipment->toCountry->iso_code,
|
'iso_code' => $shipment->toCountry->iso_code,
|
||||||
] : null,
|
] : null,
|
||||||
|
|
||||||
'user' => $shipment->user ? [
|
'user' => $shipment->user ? [
|
||||||
'id' => $shipment->user->id,
|
'id' => $shipment->user->id,
|
||||||
'name' => $shipment->user->name,
|
'name' => $shipment->user->name,
|
||||||
'email' => $shipment->user->email,
|
'email' => $shipment->user->email,
|
||||||
] : null,
|
] : null,
|
||||||
|
|
||||||
'weight' => $shipment->weight,
|
'weight' => $shipment->weight,
|
||||||
'chargeable_weight' => $shipment->chargeable_weight,
|
'chargeable_weight' => $shipment->chargeable_weight,
|
||||||
'total_fee' => $shipment->total_fee,
|
'total_fee' => $shipment->total_fee,
|
||||||
@ -190,7 +225,9 @@ class StaffOrderController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function toJalali($date): string
|
private function toJalali($date): string
|
||||||
{
|
{
|
||||||
if (!$date) return '—';
|
if (!$date) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return \Morilog\Jalali\Jalalian::fromCarbon($date)->format('Y/m/d H:i');
|
return \Morilog\Jalali\Jalalian::fromCarbon($date)->format('Y/m/d H:i');
|
||||||
|
|||||||
28
04_Laravel/app/Http/Controllers/StaffApiMiddleware.php
Normal file
28
04_Laravel/app/Http/Controllers/StaffApiMiddleware.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class StaffApiMiddleware
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (!$user || !$user->hasAnyRole([
|
||||||
|
'super_admin',
|
||||||
|
'admin',
|
||||||
|
'staff',
|
||||||
|
])) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'شما اجازه دسترسی به این بخش را ندارید.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ class Shipment extends Model
|
|||||||
'direction',
|
'direction',
|
||||||
'type',
|
'type',
|
||||||
'status',
|
'status',
|
||||||
|
'review_state',
|
||||||
'from_country_id',
|
'from_country_id',
|
||||||
'to_country_id',
|
'to_country_id',
|
||||||
// Weight & Dimensions
|
// Weight & Dimensions
|
||||||
@ -78,6 +79,7 @@ class Shipment extends Model
|
|||||||
'volumetric_weight' => 'decimal:3',
|
'volumetric_weight' => 'decimal:3',
|
||||||
'chargeable_weight' => 'decimal:3',
|
'chargeable_weight' => 'decimal:3',
|
||||||
'status' => \App\Enums\ShipmentStatus::class,
|
'status' => \App\Enums\ShipmentStatus::class,
|
||||||
|
'review_state' => \App\Enums\ReviewState::class,
|
||||||
'direction' => \App\Enums\ShipmentDirection::class,
|
'direction' => \App\Enums\ShipmentDirection::class,
|
||||||
'type' => \App\Enums\ShipmentType::class,
|
'type' => \App\Enums\ShipmentType::class,
|
||||||
];
|
];
|
||||||
@ -94,6 +96,11 @@ class Shipment extends Model
|
|||||||
return $this->belongsTo(Country::class, 'to_country_id');
|
return $this->belongsTo(Country::class, 'to_country_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function items(): HasMany
|
public function items(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ShipmentItem::class);
|
return $this->hasMany(ShipmentItem::class);
|
||||||
@ -117,6 +124,12 @@ class Shipment extends Model
|
|||||||
return $this->hasMany(ShipmentPackage::class);
|
return $this->hasMany(ShipmentPackage::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reviews(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ShipmentReview::class)
|
||||||
|
->orderBy('revision_no');
|
||||||
|
}
|
||||||
|
|
||||||
// === Scopes ===
|
// === Scopes ===
|
||||||
|
|
||||||
public function scopeByAwb($query, string $awbNo)
|
public function scopeByAwb($query, string $awbNo)
|
||||||
|
|||||||
54
04_Laravel/app/Models/ShipmentReview.php
Normal file
54
04_Laravel/app/Models/ShipmentReview.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ShipmentReview extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'shipment_id',
|
||||||
|
'revision_no',
|
||||||
|
'decision',
|
||||||
|
'reason',
|
||||||
|
'notes',
|
||||||
|
'reviewed_by',
|
||||||
|
'reviewed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'revision_no' => 'integer',
|
||||||
|
'reviewed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $model) {
|
||||||
|
if (
|
||||||
|
$model->decision !== 'pending' &&
|
||||||
|
empty($model->reviewed_by) &&
|
||||||
|
auth()->check()
|
||||||
|
) {
|
||||||
|
$model->reviewed_by = auth()->id();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$model->decision !== 'pending' &&
|
||||||
|
empty($model->reviewed_at)
|
||||||
|
) {
|
||||||
|
$model->reviewed_at = now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shipment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Shipment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviewedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reviewed_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,25 +11,9 @@ use InvalidArgumentException;
|
|||||||
class PriceCalculatorService
|
class PriceCalculatorService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی
|
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی.
|
||||||
*
|
*
|
||||||
* @param array $data آرایهای شامل اطلاعات محاسبه قیمت:
|
* این متد هیچ side effectای روی دیتابیس ندارد.
|
||||||
* - 'type' (string): نوع سرویس (مقدار Enum ShipmentType)
|
|
||||||
* - 'direction' (string): جهت ارسال ('Outbound' یا 'Inbound')
|
|
||||||
* - 'weight' (float): وزن واقعی بسته
|
|
||||||
* - 'volumetric_weight' (float): وزن حجمی بسته
|
|
||||||
* - 'country_iso' (string): کد ISO کشور مقصد
|
|
||||||
* - 'extra_service' (float, optional): هزینه خدمات اضافی
|
|
||||||
* - 'packing_cost' (float, optional): هزینه بستهبندی
|
|
||||||
* - 'domestic_pickup' (float, optional): هزینه دریافت داخلی
|
|
||||||
* - 'domestic_delivery' (float, optional): هزینه تحویل داخلی
|
|
||||||
* - 'warehousing_cost' (float, optional): هزینه انبارداری
|
|
||||||
* - 'discount_code' (string, optional): کد تخفیف
|
|
||||||
*
|
|
||||||
* @return array آرایهای شامل جزئیات قیمت نهایی
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException اگر کشور یافت نشود
|
|
||||||
* @throws InvalidArgumentException اگر نرخی برای پارامترهای داده شده یافت نشود
|
|
||||||
*/
|
*/
|
||||||
public function calculate(array $data): array
|
public function calculate(array $data): array
|
||||||
{
|
{
|
||||||
@ -38,7 +22,7 @@ class PriceCalculatorService
|
|||||||
$direction = $data['direction'] === 'Outbound' ? 'export' : 'import';
|
$direction = $data['direction'] === 'Outbound' ? 'export' : 'import';
|
||||||
$chargeableWeight = max($data['weight'], $data['volumetric_weight']);
|
$chargeableWeight = max($data['weight'], $data['volumetric_weight']);
|
||||||
|
|
||||||
// 2. یافتن کشور و زون مربوطه
|
// 2. یافتن کشور و zone مربوطه
|
||||||
$country = Country::where('iso_code', $data['country_iso'])->firstOrFail();
|
$country = Country::where('iso_code', $data['country_iso'])->firstOrFail();
|
||||||
|
|
||||||
$zone = match (true) {
|
$zone = match (true) {
|
||||||
@ -51,30 +35,29 @@ class PriceCalculatorService
|
|||||||
// 3. یافتن نرخ پایه
|
// 3. یافتن نرخ پایه
|
||||||
$baseRate = $this->lookupRate($direction, $type, $chargeableWeight, $zone);
|
$baseRate = $this->lookupRate($direction, $type, $chargeableWeight, $zone);
|
||||||
|
|
||||||
// 4. محاسبات مالی (تبدیل ارز، سود، مالیات)
|
// 4. محاسبات مالی
|
||||||
$financials = $this->calculateFinancials($baseRate);
|
$financials = $this->calculateFinancials($baseRate);
|
||||||
|
|
||||||
// 5. جمعآوری هزینههای جانبی
|
// 5. هزینههای جانبی
|
||||||
$extraCosts = $this->calculateExtraCosts($data);
|
$extraCosts = $this->calculateExtraCosts($data);
|
||||||
|
|
||||||
// 6. محاسبه قیمت قبل از تخفیف
|
// 6. قیمت قبل از تخفیف
|
||||||
$subtotal = $financials['net_rial'] + array_sum($extraCosts);
|
$subtotal = $financials['net_rial'] + array_sum($extraCosts);
|
||||||
|
|
||||||
// 7. اعمال کد تخفیف
|
// 7. اعمال تخفیف
|
||||||
$discountResult = $this->applyDiscount($subtotal, $data['discount_code'] ?? null);
|
// مهم: اینجا دیگر used_count تغییر نمیکند.
|
||||||
|
$discountResult = $this->applyDiscount(
|
||||||
|
$subtotal,
|
||||||
|
$data['discount_code'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
if ($discountResult['success']) {
|
// 8. محاسبات مالی نهایی
|
||||||
// افزایش شمارنده استفاده از کد تخفیف
|
|
||||||
DiscountCode::where('code', $discountResult['discount_code'])->increment('used_count');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. محاسبه مالیات و قیمت نهایی
|
|
||||||
$vatRate = (float) SystemSetting::get('vat_rate', 0.09);
|
$vatRate = (float) SystemSetting::get('vat_rate', 0.09);
|
||||||
$finalPrice = $discountResult['final_price'];
|
$finalPrice = $discountResult['final_price'];
|
||||||
$vatAmount = $finalPrice * $vatRate;
|
$vatAmount = $finalPrice * $vatRate;
|
||||||
$totalFee = $finalPrice + $vatAmount;
|
$totalFee = $finalPrice + $vatAmount;
|
||||||
|
|
||||||
// 9. بازگرداندن نتیجه نهایی
|
// 9. خروجی
|
||||||
return [
|
return [
|
||||||
'base_price' => $baseRate,
|
'base_price' => $baseRate,
|
||||||
'net_dirham' => $financials['net_dirham'],
|
'net_dirham' => $financials['net_dirham'],
|
||||||
@ -85,6 +68,7 @@ class PriceCalculatorService
|
|||||||
'domestic_delivery' => $extraCosts['domestic_delivery'],
|
'domestic_delivery' => $extraCosts['domestic_delivery'],
|
||||||
'warehousing_cost' => $extraCosts['warehousing_cost'],
|
'warehousing_cost' => $extraCosts['warehousing_cost'],
|
||||||
'discount_applied' => $discountResult['success'],
|
'discount_applied' => $discountResult['success'],
|
||||||
|
'discount_code' => $discountResult['discount_code'] ?? null,
|
||||||
'discount_amount' => $discountResult['discount_amount'],
|
'discount_amount' => $discountResult['discount_amount'],
|
||||||
'discount_message' => $discountResult['message'],
|
'discount_message' => $discountResult['message'],
|
||||||
'vat_amount' => $vatAmount,
|
'vat_amount' => $vatAmount,
|
||||||
@ -95,10 +79,46 @@ class PriceCalculatorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* محاسبه ارزش مالی نرخ پایه (با اعمال حاشیه سود و تبدیل ارز)
|
* ثبت مصرف واقعی یک کد تخفیف.
|
||||||
*
|
*
|
||||||
* @param float $baseRate نرخ پایه به درهم
|
* این متد باید فقط بعد از محاسبه موفق و داخل transaction
|
||||||
* @return array ['net_dirham' => float, 'net_rial' => float]
|
* ثبت سفارش فراخوانی شود.
|
||||||
|
*/
|
||||||
|
public function consumeDiscountCode(?string $code): void
|
||||||
|
{
|
||||||
|
if (empty($code)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$discount = DiscountCode::query()
|
||||||
|
->where('code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$discount) {
|
||||||
|
throw new InvalidArgumentException('کد تخفیف معتبر نیست.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$discount->is_active) {
|
||||||
|
throw new InvalidArgumentException('کد تخفیف غیرفعال است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($discount->expires_at && $discount->expires_at->isPast()) {
|
||||||
|
throw new InvalidArgumentException('کد تخفیف منقضی شده است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$discount->usage_limit !== null &&
|
||||||
|
$discount->used_count >= $discount->usage_limit
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('ظرفیت استفاده از این کد تخفیف به پایان رسیده است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$discount->increment('used_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* محاسبه ارزش مالی نرخ پایه.
|
||||||
*/
|
*/
|
||||||
private function calculateFinancials(float $baseRate): array
|
private function calculateFinancials(float $baseRate): array
|
||||||
{
|
{
|
||||||
@ -115,14 +135,14 @@ class PriceCalculatorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* محاسبه مجموع هزینههای جانبی
|
* محاسبه مجموع هزینههای جانبی.
|
||||||
*
|
|
||||||
* @param array $data دادههای ورودی
|
|
||||||
* @return array آرایهای شامل هزینههای جانبی
|
|
||||||
*/
|
*/
|
||||||
private function calculateExtraCosts(array $data): array
|
private function calculateExtraCosts(array $data): array
|
||||||
{
|
{
|
||||||
$packingCostDefault = (float) SystemSetting::get('packing_cost_default', 100000);
|
$packingCostDefault = (float) SystemSetting::get(
|
||||||
|
'packing_cost_default',
|
||||||
|
100000
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'extra_service' => (float) ($data['extra_service'] ?? 0),
|
'extra_service' => (float) ($data['extra_service'] ?? 0),
|
||||||
@ -134,11 +154,9 @@ class PriceCalculatorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* اعمال کد تخفیف روی قیمت نهایی
|
* اعمال کد تخفیف روی قیمت.
|
||||||
*
|
*
|
||||||
* @param float $totalPrice قیمت کل قبل از تخفیف
|
* این متد pure است و used_count را تغییر نمیدهد.
|
||||||
* @param string|null $code کد تخفیف وارد شده توسط کاربر
|
|
||||||
* @return array ['success' => bool, 'message' => string, 'discount_amount' => float, 'final_price' => float]
|
|
||||||
*/
|
*/
|
||||||
public function applyDiscount(float $totalPrice, ?string $code): array
|
public function applyDiscount(float $totalPrice, ?string $code): array
|
||||||
{
|
{
|
||||||
@ -153,7 +171,7 @@ class PriceCalculatorService
|
|||||||
|
|
||||||
$discount = DiscountCode::where('code', $code)->first();
|
$discount = DiscountCode::where('code', $code)->first();
|
||||||
|
|
||||||
// 1. بررسی وجود کد
|
// 1. بررسی وجود
|
||||||
if (!$discount) {
|
if (!$discount) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
@ -163,7 +181,7 @@ class PriceCalculatorService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. بررسی فعال بودن کد
|
// 2. بررسی فعال بودن
|
||||||
if (!$discount->is_active) {
|
if (!$discount->is_active) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
@ -183,18 +201,26 @@ class PriceCalculatorService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. بررسی حداقل مبلغ سفارش
|
// 4. حداقل مبلغ سفارش
|
||||||
if ($discount->min_order_amount > 0 && $totalPrice < $discount->min_order_amount) {
|
if (
|
||||||
|
$discount->min_order_amount > 0 &&
|
||||||
|
$totalPrice < $discount->min_order_amount
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => "حداقل مبلغ سفارش برای استفاده از این کد " . number_format($discount->min_order_amount) . " ریال است.",
|
'message' => 'حداقل مبلغ سفارش برای استفاده از این کد '
|
||||||
|
. number_format($discount->min_order_amount)
|
||||||
|
. ' ریال است.',
|
||||||
'discount_amount' => 0,
|
'discount_amount' => 0,
|
||||||
'final_price' => $totalPrice,
|
'final_price' => $totalPrice,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. بررسی محدودیت استفاده
|
// 5. محدودیت تعداد استفاده
|
||||||
if ($discount->usage_limit && $discount->used_count >= $discount->usage_limit) {
|
if (
|
||||||
|
$discount->usage_limit &&
|
||||||
|
$discount->used_count >= $discount->usage_limit
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'ظرفیت استفاده از این کد تخفیف به پایان رسیده است.',
|
'message' => 'ظرفیت استفاده از این کد تخفیف به پایان رسیده است.',
|
||||||
@ -203,7 +229,7 @@ class PriceCalculatorService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// محاسبه مبلغ تخفیف
|
// محاسبه تخفیف
|
||||||
$discountAmount = 0;
|
$discountAmount = 0;
|
||||||
|
|
||||||
if ($discount->type === 'percentage') {
|
if ($discount->type === 'percentage') {
|
||||||
@ -212,7 +238,7 @@ class PriceCalculatorService
|
|||||||
$discountAmount = $discount->value;
|
$discountAmount = $discount->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// اطمینان از اینکه تخفیف بیشتر از مبلغ کل نشود
|
// جلوگیری از تخفیف بیشتر از مبلغ سفارش
|
||||||
$discountAmount = min($discountAmount, $totalPrice);
|
$discountAmount = min($discountAmount, $totalPrice);
|
||||||
$finalPrice = $totalPrice - $discountAmount;
|
$finalPrice = $totalPrice - $discountAmount;
|
||||||
|
|
||||||
@ -221,23 +247,19 @@ class PriceCalculatorService
|
|||||||
'message' => 'کد تخفیف با موفقیت اعمال شد.',
|
'message' => 'کد تخفیف با موفقیت اعمال شد.',
|
||||||
'discount_amount' => $discountAmount,
|
'discount_amount' => $discountAmount,
|
||||||
'final_price' => $finalPrice,
|
'final_price' => $finalPrice,
|
||||||
'discount_code' => $discount->code, // برای ذخیره در سفارش
|
'discount_code' => $discount->code,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* جستجوی نرخ حمل و نقل در دیتابیس
|
* جستجوی نرخ حمل و نقل در دیتابیس.
|
||||||
*
|
|
||||||
* @param string $direction جهت ارسال (export/import)
|
|
||||||
* @param ShipmentType $type نوع سرویس
|
|
||||||
* @param float $weight وزن قابل پرداخت
|
|
||||||
* @param int $zone شماره زون
|
|
||||||
* @return float نرخ پیدا شده
|
|
||||||
*
|
|
||||||
* @throws InvalidArgumentException اگر نرخی یافت نشود
|
|
||||||
*/
|
*/
|
||||||
private function lookupRate(string $direction, ShipmentType $type, float $weight, int $zone): float
|
private function lookupRate(
|
||||||
{
|
string $direction,
|
||||||
|
ShipmentType $type,
|
||||||
|
float $weight,
|
||||||
|
int $zone
|
||||||
|
): float {
|
||||||
$zoneColumn = 'zone_' . $zone;
|
$zoneColumn = 'zone_' . $zone;
|
||||||
|
|
||||||
$rate = \App\Models\ShippingRate::query()
|
$rate = \App\Models\ShippingRate::query()
|
||||||
@ -249,7 +271,9 @@ class PriceCalculatorService
|
|||||||
->value($zoneColumn);
|
->value($zoneColumn);
|
||||||
|
|
||||||
if ($rate === null) {
|
if ($rate === null) {
|
||||||
throw new InvalidArgumentException('No rate found for the given parameters.');
|
throw new InvalidArgumentException(
|
||||||
|
'No rate found for the given parameters.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (float) $rate;
|
return (float) $rate;
|
||||||
|
|||||||
179
04_Laravel/app/Services/ShipmentReviewService.php
Normal file
179
04_Laravel/app/Services/ShipmentReviewService.php
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\ReviewState;
|
||||||
|
use App\Enums\ShipmentStatus;
|
||||||
|
use App\Models\Shipment;
|
||||||
|
use App\Models\ShipmentReview;
|
||||||
|
use App\Models\ShipmentStatusHistory;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ShipmentReviewService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* درخواست اصلاح سفارش توسط کارمند.
|
||||||
|
*/
|
||||||
|
public function requestChanges(
|
||||||
|
Shipment $shipment,
|
||||||
|
User $user,
|
||||||
|
string $reason,
|
||||||
|
?string $notes = null
|
||||||
|
): Shipment {
|
||||||
|
return DB::transaction(function () use ($shipment, $user, $reason, $notes) {
|
||||||
|
|
||||||
|
$shipment = Shipment::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($shipment->id);
|
||||||
|
|
||||||
|
$this->assertPendingReview(
|
||||||
|
$shipment,
|
||||||
|
'این سفارش در وضعیت قابل درخواست اصلاح نیست.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$revisionNo = $this->nextReviewRevision($shipment);
|
||||||
|
|
||||||
|
$shipment->update([
|
||||||
|
'review_state' => ReviewState::ChangesRequested,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ShipmentReview::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'revision_no' => $revisionNo,
|
||||||
|
'decision' => ReviewState::ChangesRequested->value,
|
||||||
|
'reason' => $reason,
|
||||||
|
'notes' => $notes,
|
||||||
|
'reviewed_by' => $user->id,
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $shipment->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تأیید سفارش توسط کارمند.
|
||||||
|
*/
|
||||||
|
public function approve(
|
||||||
|
Shipment $shipment,
|
||||||
|
User $user,
|
||||||
|
?string $notes = null
|
||||||
|
): Shipment {
|
||||||
|
return DB::transaction(function () use ($shipment, $user, $notes) {
|
||||||
|
|
||||||
|
$shipment = Shipment::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($shipment->id);
|
||||||
|
|
||||||
|
$this->assertPendingReview(
|
||||||
|
$shipment,
|
||||||
|
'این سفارش در وضعیت قابل تأیید نیست.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$oldStatus = $shipment->status;
|
||||||
|
$revisionNo = $this->nextReviewRevision($shipment);
|
||||||
|
|
||||||
|
$shipment->update([
|
||||||
|
'status' => ShipmentStatus::Approved,
|
||||||
|
'review_state' => ReviewState::Approved,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ShipmentStatusHistory::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'from_status' => $oldStatus->value,
|
||||||
|
'to_status' => ShipmentStatus::Approved->value,
|
||||||
|
'reason' => $notes ?: 'تأیید توسط کارمند',
|
||||||
|
'changed_by' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ShipmentReview::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'revision_no' => $revisionNo,
|
||||||
|
'decision' => ReviewState::Approved->value,
|
||||||
|
'reason' => null,
|
||||||
|
'notes' => $notes,
|
||||||
|
'reviewed_by' => $user->id,
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $shipment->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* رد سفارش توسط کارمند.
|
||||||
|
*/
|
||||||
|
public function reject(
|
||||||
|
Shipment $shipment,
|
||||||
|
User $user,
|
||||||
|
string $reason
|
||||||
|
): Shipment {
|
||||||
|
return DB::transaction(function () use ($shipment, $user, $reason) {
|
||||||
|
|
||||||
|
$shipment = Shipment::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($shipment->id);
|
||||||
|
|
||||||
|
$this->assertPendingReview(
|
||||||
|
$shipment,
|
||||||
|
'این سفارش در وضعیت قابل رد نیست.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$oldStatus = $shipment->status;
|
||||||
|
$revisionNo = $this->nextReviewRevision($shipment);
|
||||||
|
|
||||||
|
$shipment->update([
|
||||||
|
'status' => ShipmentStatus::Cancelled,
|
||||||
|
'review_state' => ReviewState::Rejected,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ShipmentStatusHistory::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'from_status' => $oldStatus->value,
|
||||||
|
'to_status' => ShipmentStatus::Cancelled->value,
|
||||||
|
'reason' => 'رد شده: ' . $reason,
|
||||||
|
'changed_by' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ShipmentReview::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'revision_no' => $revisionNo,
|
||||||
|
'decision' => ReviewState::Rejected->value,
|
||||||
|
'reason' => $reason,
|
||||||
|
'notes' => null,
|
||||||
|
'reviewed_by' => $user->id,
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $shipment->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بررسی وضعیت فعلی برای عملیات Review.
|
||||||
|
*
|
||||||
|
* این check داخل transaction و بعد از lock انجام میشود
|
||||||
|
* تا race condition بین دو کارمند کاهش یابد.
|
||||||
|
*/
|
||||||
|
private function assertPendingReview(
|
||||||
|
Shipment $shipment,
|
||||||
|
string $message
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
$shipment->status !== ShipmentStatus::PendingApproval ||
|
||||||
|
$shipment->review_state !== ReviewState::Pending
|
||||||
|
) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تعیین revision بعدی.
|
||||||
|
*/
|
||||||
|
private function nextReviewRevision(Shipment $shipment): int
|
||||||
|
{
|
||||||
|
return ((int) $shipment->reviews()->max('revision_no')) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
<?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::hasColumn('shipment_status_histories', 'from_status')) {
|
||||||
|
Schema::table('shipment_status_histories', function (Blueprint $table) {
|
||||||
|
$table->string('from_status', 30)
|
||||||
|
->nullable()
|
||||||
|
->after('shipment_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasColumn('shipment_status_histories', 'notes')) {
|
||||||
|
Schema::table('shipment_status_histories', function (Blueprint $table) {
|
||||||
|
$table->text('notes')
|
||||||
|
->nullable()
|
||||||
|
->after('reason');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('shipment_status_histories', 'notes')) {
|
||||||
|
Schema::table('shipment_status_histories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('notes');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('shipment_status_histories', 'from_status')) {
|
||||||
|
Schema::table('shipment_status_histories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('from_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('shipments', function (Blueprint $table) {
|
||||||
|
$table->string('review_state', 30)
|
||||||
|
->default('pending')
|
||||||
|
->index()
|
||||||
|
->after('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backfill existing shipments based on their current shipment status.
|
||||||
|
DB::table('shipments')
|
||||||
|
->where('status', 'pending_approval')
|
||||||
|
->update(['review_state' => 'pending']);
|
||||||
|
|
||||||
|
DB::table('shipments')
|
||||||
|
->whereIn('status', [
|
||||||
|
'approved',
|
||||||
|
'pending_payment',
|
||||||
|
'processed',
|
||||||
|
'picked_up',
|
||||||
|
'in_transit',
|
||||||
|
'out_for_delivery',
|
||||||
|
'failed',
|
||||||
|
'delivered',
|
||||||
|
'returned',
|
||||||
|
'archived',
|
||||||
|
])
|
||||||
|
->update(['review_state' => 'approved']);
|
||||||
|
|
||||||
|
DB::table('shipments')
|
||||||
|
->where('status', 'cancelled')
|
||||||
|
->update(['review_state' => 'rejected']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('shipments', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('review_state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('shipment_reviews', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
$table->foreignId('shipment_id')
|
||||||
|
->constrained('shipments')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->unsignedInteger('revision_no');
|
||||||
|
|
||||||
|
$table->string('decision', 30);
|
||||||
|
|
||||||
|
$table->text('reason')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
|
||||||
|
$table->foreignId('reviewed_by')
|
||||||
|
->nullable()
|
||||||
|
->constrained('users')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamp('reviewed_at')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['shipment_id', 'revision_no']);
|
||||||
|
$table->index(['shipment_id', 'decision']);
|
||||||
|
$table->index(['reviewed_by']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('shipment_reviews');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -75,6 +75,7 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
|||||||
Route::post('/orders', [CustomerOrderController::class, 'store']);
|
Route::post('/orders', [CustomerOrderController::class, 'store']);
|
||||||
Route::get('/orders/{shipment}', [CustomerOrderController::class, 'show']);
|
Route::get('/orders/{shipment}', [CustomerOrderController::class, 'show']);
|
||||||
Route::post('/orders/{shipment}/cancel', [CustomerOrderController::class, 'cancel']);
|
Route::post('/orders/{shipment}/cancel', [CustomerOrderController::class, 'cancel']);
|
||||||
|
Route::post('/orders/{shipment}/resubmit', [CustomerOrderController::class, 'resubmit']);
|
||||||
|
|
||||||
// دانلود PDFهای سفارش
|
// دانلود PDFهای سفارش
|
||||||
Route::get('/orders/{shipment}/pdf/awb', [ShipmentPdfController::class, 'awb']);
|
Route::get('/orders/{shipment}/pdf/awb', [ShipmentPdfController::class, 'awb']);
|
||||||
@ -96,19 +97,42 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Staff Orders API (تأیید سفارشات) ───
|
// ─── Staff Orders API (تأیید سفارشات) ───
|
||||||
Route::prefix('staff')->group(function () {
|
Route::middleware([\App\Http\Middleware\StaffApiMiddleware::class])
|
||||||
// لیست سفارشات در انتظار تأیید
|
->prefix('staff')
|
||||||
Route::get('/orders/pending-approval', [StaffOrderController::class, 'pendingApproval']);
|
->group(function () {
|
||||||
|
|
||||||
// تأیید یا رد سفارش
|
Route::get(
|
||||||
Route::post('/orders/{shipment}/approve', [StaffOrderController::class, 'approve']);
|
'/orders/pending-approval',
|
||||||
Route::post('/orders/{shipment}/reject', [StaffOrderController::class, 'reject']);
|
[StaffOrderController::class, 'pendingApproval']
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Customer Financial API (وضعیت مالی مشتری) ───
|
Route::post(
|
||||||
Route::get('/customers/search', [CustomerFinancialController::class, 'search']);
|
'/orders/{shipment}/request-changes',
|
||||||
Route::get('/customers/{customer}/financial-status', [CustomerFinancialController::class, 'financialStatus']);
|
[StaffOrderController::class, 'requestChanges']
|
||||||
|
);
|
||||||
|
|
||||||
|
Route::post(
|
||||||
|
'/orders/{shipment}/approve',
|
||||||
|
[StaffOrderController::class, 'approve']
|
||||||
|
);
|
||||||
|
|
||||||
|
Route::post(
|
||||||
|
'/orders/{shipment}/reject',
|
||||||
|
[StaffOrderController::class, 'reject']
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Customer Financial API ───
|
||||||
|
Route::get(
|
||||||
|
'/customers/search',
|
||||||
|
[CustomerFinancialController::class, 'search']
|
||||||
|
);
|
||||||
|
|
||||||
|
Route::get(
|
||||||
|
'/customers/{customer}/financial-status',
|
||||||
|
[CustomerFinancialController::class, 'financialStatus']
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// Mock Gateway Routes (عمومی - برای شبیهسازی درگاه)
|
// Mock Gateway Routes (عمومی - برای شبیهسازی درگاه)
|
||||||
|
|||||||
277
IFNEX Logistics.md
Normal file
277
IFNEX Logistics.md
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
# IFNEX Logistics — Architecture Decisions
|
||||||
|
|
||||||
|
## ADR-001 — Shipment Review Workflow
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The current order workflow uses `Shipment.status` for operational, approval, and payment-related states simultaneously.
|
||||||
|
|
||||||
|
Current flow:
|
||||||
|
|
||||||
|
`pending_approval → approved → payment → processed → ...`
|
||||||
|
|
||||||
|
There is currently no independent representation for:
|
||||||
|
|
||||||
|
* review decision
|
||||||
|
* requested customer changes
|
||||||
|
* review history
|
||||||
|
* customer resubmission
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Introduce an independent Review State and Review History domain without immediately removing or redesigning the existing `ShipmentStatus` enum.
|
||||||
|
|
||||||
|
### Transitional model
|
||||||
|
|
||||||
|
`Shipment.status` remains backward-compatible:
|
||||||
|
|
||||||
|
* `pending_approval`
|
||||||
|
* `approved`
|
||||||
|
* `cancelled`
|
||||||
|
* operational statuses
|
||||||
|
* legacy `pending_payment`
|
||||||
|
|
||||||
|
A new `Shipment.review_state` represents:
|
||||||
|
|
||||||
|
* `pending`
|
||||||
|
* `changes_requested`
|
||||||
|
* `approved`
|
||||||
|
* `rejected`
|
||||||
|
|
||||||
|
### Target workflow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create Order
|
||||||
|
↓
|
||||||
|
review_state = pending
|
||||||
|
status = pending_approval
|
||||||
|
↓
|
||||||
|
Staff Review
|
||||||
|
├── Request Changes
|
||||||
|
│ ↓
|
||||||
|
│ review_state = changes_requested
|
||||||
|
│ ↓
|
||||||
|
│ Customer Edit
|
||||||
|
│ ↓
|
||||||
|
│ Resubmit
|
||||||
|
│ ↓
|
||||||
|
│ review_state = pending
|
||||||
|
│
|
||||||
|
├── Approve
|
||||||
|
│ ↓
|
||||||
|
│ review_state = approved
|
||||||
|
│ status = approved
|
||||||
|
│
|
||||||
|
└── Reject
|
||||||
|
↓
|
||||||
|
review_state = rejected
|
||||||
|
status = cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
This allows the current client workflow to work without immediately breaking existing code that depends on `ShipmentStatus`.
|
||||||
|
|
||||||
|
Long term, approval/review state can be fully separated from operational Shipment status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-002 — Shipment Review History
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Create a dedicated `shipment_reviews` domain instead of storing review reasons and decisions directly on `shipments` or using `ShipmentStatusHistory` as a substitute.
|
||||||
|
|
||||||
|
A review record should contain at minimum:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id
|
||||||
|
shipment_id
|
||||||
|
revision_no
|
||||||
|
decision
|
||||||
|
reason
|
||||||
|
notes
|
||||||
|
reviewed_by
|
||||||
|
reviewed_at
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
The review record represents one submission/review cycle.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
`ShipmentStatusHistory` records operational status transitions. Review decisions are a different domain concern and require reviewer identity, reason, notes, and revision context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-003 — Customer Revision / Resubmission
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Customer correction must not be implemented as a blind PATCH against the Shipment.
|
||||||
|
|
||||||
|
A resubmission updates the current shipment aggregate transactionally while creating a new review revision.
|
||||||
|
|
||||||
|
The aggregate includes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Shipment
|
||||||
|
ShipmentPackage[]
|
||||||
|
ShipmentItem[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required behavior
|
||||||
|
|
||||||
|
Resubmission must:
|
||||||
|
|
||||||
|
1. validate the complete order payload;
|
||||||
|
2. recalculate volumetric and chargeable weight server-side;
|
||||||
|
3. recalculate pricing server-side;
|
||||||
|
4. update Shipment fields;
|
||||||
|
5. synchronize Packages;
|
||||||
|
6. synchronize Items;
|
||||||
|
7. create a new review revision;
|
||||||
|
8. return the shipment in `pending` review state.
|
||||||
|
|
||||||
|
AWB remains unchanged because the customer is revising the same order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-004 — Request Changes vs Reject
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
These are distinct actions.
|
||||||
|
|
||||||
|
### Request Changes
|
||||||
|
|
||||||
|
* order remains active;
|
||||||
|
* customer may edit;
|
||||||
|
* customer may resubmit;
|
||||||
|
* reason is mandatory;
|
||||||
|
* shipment remains operationally pre-approval.
|
||||||
|
|
||||||
|
### Reject
|
||||||
|
|
||||||
|
* order is terminated;
|
||||||
|
* shipment becomes cancelled;
|
||||||
|
* customer cannot continue the same review cycle.
|
||||||
|
|
||||||
|
API concepts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /staff/orders/{shipment}/request-changes
|
||||||
|
POST /staff/orders/{shipment}/approve
|
||||||
|
POST /staff/orders/{shipment}/reject
|
||||||
|
POST /customer/orders/{shipment}/resubmit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-005 — Payment State
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Do not derive payment state from `Shipment.status`.
|
||||||
|
|
||||||
|
The current implementation remains temporarily compatible with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Approved → payment available
|
||||||
|
```
|
||||||
|
|
||||||
|
but the target architecture is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Shipment Operational Status
|
||||||
|
Review State
|
||||||
|
Payment State
|
||||||
|
```
|
||||||
|
|
||||||
|
as separate domains.
|
||||||
|
|
||||||
|
`ShipmentStatus::isPaid()` must eventually be removed or deprecated because states such as `cancelled`, `failed`, and `returned` cannot safely imply payment completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-006 — Order Detail API Contract
|
||||||
|
|
||||||
|
Laravel is the canonical source of the Order Detail API contract.
|
||||||
|
|
||||||
|
Target response structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
shipment
|
||||||
|
├── status
|
||||||
|
├── review
|
||||||
|
├── sender
|
||||||
|
├── receiver
|
||||||
|
├── packages[]
|
||||||
|
├── items[]
|
||||||
|
├── financial
|
||||||
|
├── documents[]
|
||||||
|
└── tracking_events[]
|
||||||
|
```
|
||||||
|
|
||||||
|
WordPress must consume the canonical Laravel structure rather than relying on legacy flattened fields.
|
||||||
|
|
||||||
|
Known current contract mismatches:
|
||||||
|
|
||||||
|
* Laravel returns `sender.*`, WordPress expects `sender_name`, `sender_phone`, etc.
|
||||||
|
* Laravel returns `receiver.*`, WordPress expects flattened receiver fields.
|
||||||
|
* Laravel returns tracking event keys `date`, `description`, `location`; WordPress expects `event_date`, `event_description`, `event_time`.
|
||||||
|
|
||||||
|
These mismatches must be corrected during API/UI hardening.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-007 — Commitment Documents
|
||||||
|
|
||||||
|
`CommitmentForm` is the reusable template.
|
||||||
|
|
||||||
|
`ShipmentCommitmentForm` represents the requirement/instance for a particular shipment.
|
||||||
|
|
||||||
|
For the current client:
|
||||||
|
|
||||||
|
* physical delivery is the primary process;
|
||||||
|
* online upload is optional;
|
||||||
|
* signed-document upload must not block approval/payment unless explicitly required by business policy.
|
||||||
|
|
||||||
|
Required documents should eventually be instantiated/snapshotted per shipment instead of dynamically resolving the current active templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-008 — Operational Finance Boundary
|
||||||
|
|
||||||
|
IFNEX is not intended to become a full accounting system.
|
||||||
|
|
||||||
|
IFNEX should provide logistics-relevant financial information:
|
||||||
|
|
||||||
|
* wallet
|
||||||
|
* customer receivable/debt status
|
||||||
|
* order financial status
|
||||||
|
* payment transactions
|
||||||
|
* credit/settlement information
|
||||||
|
* audit trail
|
||||||
|
|
||||||
|
A deeper accounting system should be integrated externally through API rather than recreated inside IFNEX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-009 — Production Hardening Findings
|
||||||
|
|
||||||
|
The following findings require later hardening:
|
||||||
|
|
||||||
|
1. `ShipmentStatus::isPaid()` is semantically unsafe.
|
||||||
|
2. `Shipment::isDelivered()` compares an Enum-cast field with a string.
|
||||||
|
3. `StaffOrderController` lacks explicit role/permission authorization.
|
||||||
|
4. Order Detail sender/receiver API contract is inconsistent with WordPress.
|
||||||
|
5. Tracking event API contract is inconsistent with WordPress.
|
||||||
|
6. PDF download uses a different user-meta token key from the standard Bridge token.
|
||||||
|
7. Commitment-form shipment requirements are not currently snapshotted.
|
||||||
|
8. Customer signed-document uploads currently use public storage semantics.
|
||||||
|
9. `ShipmentPackage` and `ShipmentItem` are not included in the current detailed customer order response.
|
||||||
|
10. Payment state is coupled to Shipment status.
|
||||||
|
11. Price calculation and discount consumption require a clear distinction between preview and committed pricing.
|
||||||
|
12. Existing status/schema migration history should be preserved; do not rewrite historical migrations.
|
||||||
Loading…
Reference in New Issue
Block a user