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 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 ───
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
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;
|
||||
|
||||
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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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',
|
||||
'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)
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی
|
||||
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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::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
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