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:
Kazem Alghasi 2026-09-27 01:00:26 +03:30
parent 69aa7d1aa9
commit 61a2643dd0
17 changed files with 2195 additions and 263 deletions

View File

@ -7,6 +7,9 @@ jQuery(document).ready(function($) {
var packages = []; // آرایه بسته‌ها
var invoiceItems = []; // آرایه اقلام گمرکی
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 ───
function loadCountries() {
@ -34,6 +37,9 @@ jQuery(document).ready(function($) {
$('#ifnex-to-country').val(iran.id).trigger('change');
}
}
if (isResubmitMode) {
loadOrderForResubmit(resubmitOrderId);
}
} else {
console.error('IFNEX: Failed to load countries', res);
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() {
var $from = $('#ifnex-from-country');
var $to = $('#ifnex-to-country');
@ -197,7 +419,13 @@ jQuery(document).ready(function($) {
// وزن حجمی (محاسبه خودکار - فقط نمایش)
html += '<div class="ifnex-field">';
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>';
// توضیحات
@ -227,11 +455,24 @@ jQuery(document).ready(function($) {
var hasWeight = false;
packages.forEach(function(pkg) {
if (pkg.weight && pkg.weight > 0) {
totalWeight += parseFloat(pkg.weight);
var weight = parseFloat(pkg.weight) || 0;
var volumetricWeight = parseFloat(pkg.volumetricWeight);
if (isNaN(volumetricWeight) || volumetricWeight <= 0) {
volumetricWeight = calculateVolumetricWeight(pkg.dimensions);
}
if (weight > 0) {
totalWeight += weight;
hasWeight = true;
}
totalVolumetric += pkg.volumetricWeight || 0;
totalVolumetric += volumetricWeight;
// state را نیز normalize می‌کنیم
pkg.weight = weight;
pkg.volumetricWeight = volumetricWeight;
});
var chargeable = Math.max(totalWeight, totalVolumetric);
@ -749,29 +990,118 @@ jQuery(document).ready(function($) {
// ─── Submit Order ───
$('#ifnex-submit-order').on('click', function() {
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({
url: ifnex_ajax.ajax_url,
method: 'POST',
data: { action: 'ifnex_submit_order', nonce: ifnex_ajax.nonce, data: buildOrderData() },
data: payload,
success: function(res) {
btn.prop('disabled', false).text('✅ تأیید و ثبت سفارش');
if (res.success) {
var orderId = res.data.data.id || res.data.id;
// با فلوی جدید، کاربر به لیست سفارشات هدایت می‌شود
// (چون پرداخت فقط بعد از تأیید کارمند امکان‌پذیر است)
window.location.href = ifnex_ajax.orders_url;
var orderId = res.data && (
res.data.order_id ||
(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 {
$('#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('✅ تأیید و ثبت سفارش');
$('#ifnex-submit-error').text('خطا در ارتباط با سرور').show();
error: function(xhr) {
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 ───

View File

@ -1030,6 +1030,80 @@ function ifnex_order_detail_shortcode($atts) {
</button>
</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">
<h3>🗺️ مسیر ارسال</h3>
@ -1087,6 +1161,55 @@ function ifnex_order_detail_shortcode($atts) {
</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">
<h3>💰 اطلاعات مالی</h3>
@ -1113,46 +1236,46 @@ function ifnex_order_detail_shortcode($atts) {
</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">
<h3>👤 اطلاعات فرستنده</h3>
<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($data['sender_name'] ?? '—'); ?></span>
<span class="ifnex-info-value"><?php echo esc_html($data['sender']['name'] ?? '—'); ?></span>
</div>
<div class="ifnex-info-item">
<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 class="ifnex-info-item full">
<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>
<?php endif; ?>
<!-- اطلاعات گیرنده -->
<?php if (!empty($data['receiver_name']) || !empty($data['receiver_phone'])): ?>
<div class="ifnex-detail-section">
<h3>📮 اطلاعات گیرنده</h3>
<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($data['receiver_name'] ?? '—'); ?></span>
</div>
<div class="ifnex-info-item">
<span class="ifnex-info-label">تلفن</span>
<span class="ifnex-info-value"><?php echo esc_html($data['receiver_phone'] ?? '—'); ?></span>
</div>
<div class="ifnex-info-item full">
<span class="ifnex-info-label">آدرس</span>
<span class="ifnex-info-value"><?php echo esc_html($data['receiver_address'] ?? '—'); ?></span>
<?php if (!empty($data['receiver']['name']) || !empty($data['receiver']['phone'])): ?>
<div class="ifnex-detail-section">
<h3>📮 اطلاعات گیرنده</h3>
<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($data['receiver']['name'] ?? '—'); ?></span>
</div>
<div class="ifnex-info-item">
<span class="ifnex-info-label">تلفن</span>
<span class="ifnex-info-value"><?php echo esc_html($data['receiver']['phone'] ?? '—'); ?></span>
</div>
<div class="ifnex-info-item full">
<span class="ifnex-info-label">آدرس</span>
<span class="ifnex-info-value"><?php echo esc_html($data['receiver']['address'] ?? '—'); ?></span>
</div>
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- دانلود مدارک -->
<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-content">
<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">
<?php echo esc_html($event['event_date'] ?? ''); ?>
<?php if (!empty($event['event_time'])): ?>
- <?php echo esc_html($event['event_time']); ?>
<?php endif; ?>
<?php echo esc_html($event['date'] ?? ''); ?>
</span>
</div>
<?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');
$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('توکن احراز هویت یافت نشد.');
$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-icon"><?php echo ifnex_icon('clock', 22); ?></div>
<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>
</div>
</div>

View File

@ -145,8 +145,11 @@ class IFNEX_User_Bridge {
error_log("IFNEX API [{$method}] {$endpoint} => Status: {$code}");
if ($code === 401) {
$this->invalidate_token(get_current_user_id());
return new WP_Error('token_expired', 'توکن منقضی شده است. لطفاً دوباره وارد شوید.');
$this->invalidate_token($user_id);
return new WP_Error(
'token_expired',
'توکن منقضی شده است. لطفاً دوباره وارد شوید.'
);
}
// اگر پاسخ JSON نیست
@ -319,6 +322,24 @@ class IFNEX_User_Bridge {
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;
}
@ -359,34 +380,177 @@ class IFNEX_User_Bridge {
}
}
// ══════════════════════════════════════════════════════════════
// AJAX Handler برای لغو سفارش
// ══════════════════════════════════════════════════════════════
add_action('wp_ajax_ifnex_cancel_order', 'ifnex_cancel_order_ajax');
function ifnex_cancel_order_ajax() {
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
if (!is_user_logged_in()) {
wp_send_json_error('باید وارد شوید.');
}
$order_id = intval($_POST['order_id'] ?? 0);
if (!$order_id) {
wp_send_json_error('شناسه سفارش نامعتبر است.');
}
$bridge = new IFNEX_User_Bridge();
$result = $bridge->cancel_customer_order(get_current_user_id(), $order_id);
$result = $bridge->cancel_customer_order(
get_current_user_id(),
$order_id
);
if (is_wp_error($result)) {
wp_send_json_error($result->get_error_message());
}
wp_send_json_success('سفارش لغو شد.');
}
// 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
add_action('wp_enqueue_scripts', 'ifnex_enqueue_ajax_script');

View 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;
}
}

View File

@ -3,6 +3,9 @@
namespace App\Filament\Resources;
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\RelationManagers;
use App\Models\Shipment;
@ -53,7 +56,12 @@ class ShipmentResource extends Resource
$case->value => $case->label(),
]))
->default('processed')
->required(),
->required()
->disabled(fn (?Shipment $record): bool =>
$record !== null &&
$record->review_state !== ReviewState::Approved
)
->dehydrated(),
Forms\Components\TextInput::make('reason_for_export')
->nullable()
->label('Reason for Export'),
@ -390,6 +398,13 @@ class ShipmentResource extends Resource
->color(fn (ShipmentStatus $state): string => $state->color())
->formatStateUsing(fn (ShipmentStatus $state): string => $state->label())
->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')
->searchable()->toggleable()->label('Sender'),
Tables\Columns\TextColumn::make('receiver_name')
@ -449,33 +464,162 @@ class ShipmentResource extends Resource
]),
])
->actions([
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([
'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()
->title('سفارش با موفقیت تأیید شد')
->success()
->send();
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 (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([
@ -550,42 +694,6 @@ class ShipmentResource extends Resource
->bulkActions([
Tables\Actions\BulkActionGroup::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(),
]),
]);
}

View File

@ -8,6 +8,8 @@ use App\Models\ShipmentItem;
use App\Models\ShipmentPackage;
use App\Models\Country;
use App\Enums\ShipmentStatus;
use App\Enums\ReviewState;
use App\Models\ShipmentReview;
use App\Enums\ShipmentDirection;
use App\Enums\ShipmentType;
use App\Services\PriceCalculatorService;
@ -301,7 +303,9 @@ class CustomerOrderController extends Controller
]);
}
}
$this->calculator->consumeDiscountCode(
$priceResult['discount_code'] ?? null
);
return $shipment;
});
@ -336,7 +340,14 @@ class CustomerOrderController extends Controller
], 403);
}
$shipment->load(['fromCountry', 'toCountry', 'items', 'trackingEvents']);
$shipment->load([
'fromCountry',
'toCountry',
'packages',
'items',
'trackingEvents',
'reviews',
]);
return response()->json([
'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)
* POST /api/v1/customer/orders/{shipment}/cancel
@ -467,6 +793,7 @@ class CustomerOrderController extends Controller
'awb_no' => $shipment->awb_no,
'direction' => $shipment->direction?->value,
'type' => $shipment->type?->value,
'status' => [
'value' => $shipment->status?->value,
'label' => $shipment->status?->label(),
@ -476,16 +803,25 @@ class CustomerOrderController extends Controller
'can_pay' => $shipment->status?->canBePaid(),
'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 ? [
'id' => $shipment->fromCountry->id,
'name' => $shipment->fromCountry->name,
'iso_code' => $shipment->fromCountry->iso_code,
] : null,
'to_country' => $shipment->toCountry ? [
'id' => $shipment->toCountry->id,
'name' => $shipment->toCountry->name,
'iso_code' => $shipment->toCountry->iso_code,
] : null,
'weight' => $shipment->weight,
'chargeable_weight' => $shipment->chargeable_weight,
'total_fee' => $shipment->total_fee,
@ -496,6 +832,38 @@ class CustomerOrderController extends Controller
];
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'] = [
'name' => $shipment->sender_name,
'company' => $shipment->sender_company,
@ -504,6 +872,7 @@ class CustomerOrderController extends Controller
'address' => $shipment->sender_address,
'city' => $shipment->sender_city,
];
$data['receiver'] = [
'name' => $shipment->receiver_name,
'company' => $shipment->receiver_company,
@ -512,6 +881,17 @@ class CustomerOrderController extends Controller
'address' => $shipment->receiver_address,
'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'] = [
'shipping_price' => $shipment->shipping_price,
'extra_service' => $shipment->extra_service,
@ -523,6 +903,7 @@ class CustomerOrderController extends Controller
'discount' => $shipment->discount,
'total_fee' => $shipment->total_fee,
];
$data['items'] = $shipment->items->map(fn($item) => [
'row' => $item->row_number,
'description' => $item->description,
@ -531,6 +912,7 @@ class CustomerOrderController extends Controller
'unit_price' => $item->unit_price,
'total' => $item->total_usd,
]);
$data['tracking_events'] = $shipment->trackingEvents->map(fn($event) => [
'date' => $event->event_date,
'description' => $event->event_description,

View File

@ -4,12 +4,11 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Shipment;
use App\Models\ShipmentStatusHistory;
use App\Enums\ShipmentStatus;
use App\Models\ShipmentReview;
use App\Services\ShipmentReviewService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
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
*/
public function approve(Shipment $shipment, Request $request): JsonResponse
{
public function approve(
Shipment $shipment,
Request $request,
ShipmentReviewService $reviewService
): JsonResponse {
$user = Auth::user();
// بررسی وضعیت
if ($shipment->status !== ShipmentStatus::PendingApproval) {
return response()->json([
'success' => false,
'message' => 'این سفارش در وضعیت قابل تأیید نیست.',
], 400);
}
$validated = $request->validate([
'notes' => ['nullable', 'string', 'max:1000'],
]);
try {
DB::transaction(function () use ($shipment, $user, $validated) {
// تغییر وضعیت به Approved
$oldStatus = $shipment->status;
$shipment->update([
'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,
]);
});
$shipment = $reviewService->approve(
$shipment,
$user,
$validated['notes'] ?? null
);
return response()->json([
'success' => true,
'message' => 'سفارش با موفقیت تأیید شد. مشتری می‌تواند پرداخت را انجام دهد.',
'data' => $this->formatShipment($shipment->fresh()->load(['fromCountry', 'toCountry'])),
'data' => $this->formatShipment(
$shipment->load(['fromCountry', 'toCountry'])
),
]);
} catch (\Exception $e) {
} catch (\RuntimeException $e) {
return response()->json([
'success' => false,
'message' => 'خطا در تأیید سفارش: ' . $e->getMessage(),
'message' => $e->getMessage(),
], 400);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => 'خطا در تأیید سفارش.',
], 500);
}
}
@ -99,54 +132,45 @@ class StaffOrderController extends Controller
* رد سفارش توسط کارمند
* 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();
// بررسی وضعیت
if ($shipment->status !== ShipmentStatus::PendingApproval) {
return response()->json([
'success' => false,
'message' => 'این سفارش در وضعیت قابل رد نیست.',
], 400);
}
$validated = $request->validate([
'reason' => ['required', 'string', 'max:1000'],
]);
try {
DB::transaction(function () use ($shipment, $user, $validated) {
// تغییر وضعیت به Cancelled
$oldStatus = $shipment->status;
$shipment->update([
'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,
]);
});
$shipment = $reviewService->reject(
$shipment,
$user,
$validated['reason']
);
return response()->json([
'success' => true,
'message' => 'سفارش رد شد.',
'data' => $this->formatShipment($shipment->fresh()->load(['fromCountry', 'toCountry'])),
'data' => $this->formatShipment(
$shipment->load(['fromCountry', 'toCountry'])
),
]);
} catch (\Exception $e) {
} catch (\RuntimeException $e) {
return response()->json([
'success' => false,
'message' => 'خطا در رد سفارش: ' . $e->getMessage(),
'message' => $e->getMessage(),
], 400);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => 'خطا در رد سفارش.',
], 500);
}
}
/**
* فرمت کردن Shipment برای خروجی API
*/
@ -157,26 +181,37 @@ class StaffOrderController extends Controller
'awb_no' => $shipment->awb_no,
'direction' => $shipment->direction?->value,
'type' => $shipment->type?->value,
'status' => [
'value' => $shipment->status?->value,
'label' => $shipment->status?->label(),
'color' => $shipment->status?->color(),
],
'review' => [
'state' => $shipment->review_state?->value,
'label' => $shipment->review_state?->label(),
'color' => $shipment->review_state?->color(),
],
'from_country' => $shipment->fromCountry ? [
'id' => $shipment->fromCountry->id,
'name' => $shipment->fromCountry->name,
'iso_code' => $shipment->fromCountry->iso_code,
] : null,
'to_country' => $shipment->toCountry ? [
'id' => $shipment->toCountry->id,
'name' => $shipment->toCountry->name,
'iso_code' => $shipment->toCountry->iso_code,
] : null,
'user' => $shipment->user ? [
'id' => $shipment->user->id,
'name' => $shipment->user->name,
'email' => $shipment->user->email,
] : null,
'weight' => $shipment->weight,
'chargeable_weight' => $shipment->chargeable_weight,
'total_fee' => $shipment->total_fee,
@ -190,12 +225,14 @@ class StaffOrderController extends Controller
*/
private function toJalali($date): string
{
if (!$date) return '—';
if (!$date) {
return '—';
}
try {
return \Morilog\Jalali\Jalalian::fromCarbon($date)->format('Y/m/d H:i');
} catch (\Exception $e) {
return $date->format('Y-m-d H:i');
}
}
}
}

View File

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

View File

@ -16,6 +16,7 @@ class Shipment extends Model
'direction',
'type',
'status',
'review_state',
'from_country_id',
'to_country_id',
// Weight & Dimensions
@ -78,6 +79,7 @@ class Shipment extends Model
'volumetric_weight' => 'decimal:3',
'chargeable_weight' => 'decimal:3',
'status' => \App\Enums\ShipmentStatus::class,
'review_state' => \App\Enums\ReviewState::class,
'direction' => \App\Enums\ShipmentDirection::class,
'type' => \App\Enums\ShipmentType::class,
];
@ -94,6 +96,11 @@ class Shipment extends Model
return $this->belongsTo(Country::class, 'to_country_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(ShipmentItem::class);
@ -117,6 +124,12 @@ class Shipment extends Model
return $this->hasMany(ShipmentPackage::class);
}
public function reviews(): HasMany
{
return $this->hasMany(ShipmentReview::class)
->orderBy('revision_no');
}
// === Scopes ===
public function scopeByAwb($query, string $awbNo)

View 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');
}
}

View File

@ -11,25 +11,9 @@ use InvalidArgumentException;
class PriceCalculatorService
{
/**
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی.
*
* @param array $data آرایه‌ای شامل اطلاعات محاسبه قیمت:
* - '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 اگر نرخی برای پارامترهای داده شده یافت نشود
* این متد هیچ side effectای روی دیتابیس ندارد.
*/
public function calculate(array $data): array
{
@ -38,7 +22,7 @@ class PriceCalculatorService
$direction = $data['direction'] === 'Outbound' ? 'export' : 'import';
$chargeableWeight = max($data['weight'], $data['volumetric_weight']);
// 2. یافتن کشور و زون مربوطه
// 2. یافتن کشور و zone مربوطه
$country = Country::where('iso_code', $data['country_iso'])->firstOrFail();
$zone = match (true) {
@ -51,30 +35,29 @@ class PriceCalculatorService
// 3. یافتن نرخ پایه
$baseRate = $this->lookupRate($direction, $type, $chargeableWeight, $zone);
// 4. محاسبات مالی (تبدیل ارز، سود، مالیات)
// 4. محاسبات مالی
$financials = $this->calculateFinancials($baseRate);
// 5. جمع‌آوری هزینه‌های جانبی
// 5. هزینه‌های جانبی
$extraCosts = $this->calculateExtraCosts($data);
// 6. محاسبه قیمت قبل از تخفیف
// 6. قیمت قبل از تخفیف
$subtotal = $financials['net_rial'] + array_sum($extraCosts);
// 7. اعمال کد تخفیف
$discountResult = $this->applyDiscount($subtotal, $data['discount_code'] ?? null);
// 7. اعمال تخفیف
// مهم: اینجا دیگر used_count تغییر نمی‌کند.
$discountResult = $this->applyDiscount(
$subtotal,
$data['discount_code'] ?? null
);
if ($discountResult['success']) {
// افزایش شمارنده استفاده از کد تخفیف
DiscountCode::where('code', $discountResult['discount_code'])->increment('used_count');
}
// 8. محاسبه مالیات و قیمت نهایی
// 8. محاسبات مالی نهایی
$vatRate = (float) SystemSetting::get('vat_rate', 0.09);
$finalPrice = $discountResult['final_price'];
$vatAmount = $finalPrice * $vatRate;
$totalFee = $finalPrice + $vatAmount;
// 9. بازگرداندن نتیجه نهایی
// 9. خروجی
return [
'base_price' => $baseRate,
'net_dirham' => $financials['net_dirham'],
@ -85,6 +68,7 @@ class PriceCalculatorService
'domestic_delivery' => $extraCosts['domestic_delivery'],
'warehousing_cost' => $extraCosts['warehousing_cost'],
'discount_applied' => $discountResult['success'],
'discount_code' => $discountResult['discount_code'] ?? null,
'discount_amount' => $discountResult['discount_amount'],
'discount_message' => $discountResult['message'],
'vat_amount' => $vatAmount,
@ -95,10 +79,46 @@ class PriceCalculatorService
}
/**
* محاسبه ارزش مالی نرخ پایه (با اعمال حاشیه سود و تبدیل ارز)
* ثبت مصرف واقعی یک کد تخفیف.
*
* @param float $baseRate نرخ پایه به درهم
* @return array ['net_dirham' => float, 'net_rial' => float]
* این متد باید فقط بعد از محاسبه موفق و داخل transaction
* ثبت سفارش فراخوانی شود.
*/
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
{
@ -115,14 +135,14 @@ class PriceCalculatorService
}
/**
* محاسبه مجموع هزینه‌های جانبی
*
* @param array $data داده‌های ورودی
* @return array آرایه‌ای شامل هزینه‌های جانبی
* محاسبه مجموع هزینه‌های جانبی.
*/
private function calculateExtraCosts(array $data): array
{
$packingCostDefault = (float) SystemSetting::get('packing_cost_default', 100000);
$packingCostDefault = (float) SystemSetting::get(
'packing_cost_default',
100000
);
return [
'extra_service' => (float) ($data['extra_service'] ?? 0),
@ -134,11 +154,9 @@ class PriceCalculatorService
}
/**
* اعمال کد تخفیف روی قیمت نهایی
* اعمال کد تخفیف روی قیمت.
*
* @param float $totalPrice قیمت کل قبل از تخفیف
* @param string|null $code کد تخفیف وارد شده توسط کاربر
* @return array ['success' => bool, 'message' => string, 'discount_amount' => float, 'final_price' => float]
* این متد pure است و used_count را تغییر نمی‌دهد.
*/
public function applyDiscount(float $totalPrice, ?string $code): array
{
@ -153,7 +171,7 @@ class PriceCalculatorService
$discount = DiscountCode::where('code', $code)->first();
// 1. بررسی وجود کد
// 1. بررسی وجود
if (!$discount) {
return [
'success' => false,
@ -163,7 +181,7 @@ class PriceCalculatorService
];
}
// 2. بررسی فعال بودن کد
// 2. بررسی فعال بودن
if (!$discount->is_active) {
return [
'success' => false,
@ -183,18 +201,26 @@ class PriceCalculatorService
];
}
// 4. بررسی حداقل مبلغ سفارش
if ($discount->min_order_amount > 0 && $totalPrice < $discount->min_order_amount) {
// 4. حداقل مبلغ سفارش
if (
$discount->min_order_amount > 0 &&
$totalPrice < $discount->min_order_amount
) {
return [
'success' => false,
'message' => "حداقل مبلغ سفارش برای استفاده از این کد " . number_format($discount->min_order_amount) . " ریال است.",
'message' => 'حداقل مبلغ سفارش برای استفاده از این کد '
. number_format($discount->min_order_amount)
. ' ریال است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 5. بررسی محدودیت استفاده
if ($discount->usage_limit && $discount->used_count >= $discount->usage_limit) {
// 5. محدودیت تعداد استفاده
if (
$discount->usage_limit &&
$discount->used_count >= $discount->usage_limit
) {
return [
'success' => false,
'message' => 'ظرفیت استفاده از این کد تخفیف به پایان رسیده است.',
@ -203,7 +229,7 @@ class PriceCalculatorService
];
}
// محاسبه مبلغ تخفیف
// محاسبه تخفیف
$discountAmount = 0;
if ($discount->type === 'percentage') {
@ -212,7 +238,7 @@ class PriceCalculatorService
$discountAmount = $discount->value;
}
// اطمینان از اینکه تخفیف بیشتر از مبلغ کل نشود
// جلوگیری از تخفیف بیشتر از مبلغ سفارش
$discountAmount = min($discountAmount, $totalPrice);
$finalPrice = $totalPrice - $discountAmount;
@ -221,23 +247,19 @@ class PriceCalculatorService
'message' => 'کد تخفیف با موفقیت اعمال شد.',
'discount_amount' => $discountAmount,
'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;
$rate = \App\Models\ShippingRate::query()
@ -249,9 +271,11 @@ class PriceCalculatorService
->value($zoneColumn);
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;
}
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View File

@ -75,6 +75,7 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
Route::post('/orders', [CustomerOrderController::class, 'store']);
Route::get('/orders/{shipment}', [CustomerOrderController::class, 'show']);
Route::post('/orders/{shipment}/cancel', [CustomerOrderController::class, 'cancel']);
Route::post('/orders/{shipment}/resubmit', [CustomerOrderController::class, 'resubmit']);
// دانلود PDFهای سفارش
Route::get('/orders/{shipment}/pdf/awb', [ShipmentPdfController::class, 'awb']);
@ -96,19 +97,42 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
});
// ─── Staff Orders API (تأیید سفارشات) ───
Route::prefix('staff')->group(function () {
// لیست سفارشات در انتظار تأیید
Route::get('/orders/pending-approval', [StaffOrderController::class, 'pendingApproval']);
// تأیید یا رد سفارش
Route::post('/orders/{shipment}/approve', [StaffOrderController::class, 'approve']);
Route::post('/orders/{shipment}/reject', [StaffOrderController::class, 'reject']);
Route::middleware([\App\Http\Middleware\StaffApiMiddleware::class])
->prefix('staff')
->group(function () {
// ─── Customer Financial API (وضعیت مالی مشتری) ───
Route::get('/customers/search', [CustomerFinancialController::class, 'search']);
Route::get('/customers/{customer}/financial-status', [CustomerFinancialController::class, 'financialStatus']);
Route::get(
'/orders/pending-approval',
[StaffOrderController::class, 'pendingApproval']
);
Route::post(
'/orders/{shipment}/request-changes',
[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 (عمومی - برای شبیه‌سازی درگاه)

277
IFNEX Logistics.md Normal file
View 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.