diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js index 8492086..bb87e08 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js @@ -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( + '
' + + '✏️ اصلاح سفارش' + + '

' + + 'اطلاعات سفارش قبلی در فرم قرار گرفته است. موارد موردنظر را اصلاح کنید و سپس قیمت را دوباره محاسبه و سفارش را ارسال کنید.' + + '

' + + '
' + ); + + } + + 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 += '
'; html += ''; - html += ''; + var volumetricDisplay = parseFloat(pkg.volumetricWeight); + + if (isNaN(volumetricDisplay) || volumetricDisplay <= 0) { + volumetricDisplay = calculateVolumetricWeight(pkg.dimensions); + } + + html += ''; html += '
'; // توضیحات @@ -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 ─── diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php index b77ef0c..2091f44 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php @@ -1030,6 +1030,80 @@ function ifnex_order_detail_shortcode($atts) { + +
+
+

🔎 وضعیت بررسی سفارش

+ + + + +
+ + +
+

⚠️ نیاز به اصلاح اطلاعات سفارش

+ + +

+ دلیل: + +

+ + + +

+ توضیحات: + +

+ + + + + ✏️ اصلاح و ارسال مجدد سفارش + + +
+ + + +
+ تاریخچه بررسی سفارش + +
+ +
+
+ + Revision + + + + + +
+ + +

+ + + +

+ + + + + +
+ +
+
+ +
+ +

🗺️ مسیر ارسال

@@ -1087,6 +1161,55 @@ function ifnex_order_detail_shortcode($atts) {
+ +
+

📦 بسته‌های مرسوله

+ +
+ +
+
+ بسته + + + +
+ +
+
+ وزن + + kg + +
+ +
+ وزن حجمی + + kg + +
+ +
+ وزن قابل محاسبه + + kg + +
+ +
+ ابعاد + + + +
+
+
+ +
+
+ +

💰 اطلاعات مالی

@@ -1113,46 +1236,46 @@ function ifnex_order_detail_shortcode($atts) {
- +

👤 اطلاعات فرستنده

نام - +
تلفن - +
آدرس - +
- -
-

📮 اطلاعات گیرنده

-
-
- نام - -
-
- تلفن - -
-
- آدرس - + +
+

📮 اطلاعات گیرنده

+
+
+ نام + +
+
+ تلفن + +
+
+ آدرس + +
-
- +
@@ -1199,12 +1322,9 @@ function ifnex_order_detail_shortcode($atts) {
- + - - - - - +
@@ -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) {
- + در انتظار پرداخت
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php index be7c0a6..2ab0c2b 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php @@ -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'); diff --git a/04_Laravel/app/Enums/ReviewState.php b/04_Laravel/app/Enums/ReviewState.php new file mode 100644 index 0000000..1d269f4 --- /dev/null +++ b/04_Laravel/app/Enums/ReviewState.php @@ -0,0 +1,56 @@ + 'در انتظار بررسی', + 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; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/ShipmentResource.php b/04_Laravel/app/Filament/Resources/ShipmentResource.php index f3c6c3c..1073311 100644 --- a/04_Laravel/app/Filament/Resources/ShipmentResource.php +++ b/04_Laravel/app/Filament/Resources/ShipmentResource.php @@ -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(), ]), ]); } diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php index 4cbe7ba..ea9559d 100644 --- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php +++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php @@ -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, diff --git a/04_Laravel/app/Http/Controllers/Api/StaffOrderController.php b/04_Laravel/app/Http/Controllers/Api/StaffOrderController.php index a6f2c37..9de4c23 100644 --- a/04_Laravel/app/Http/Controllers/Api/StaffOrderController.php +++ b/04_Laravel/app/Http/Controllers/Api/StaffOrderController.php @@ -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'); } } -} +} \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/StaffApiMiddleware.php b/04_Laravel/app/Http/Controllers/StaffApiMiddleware.php new file mode 100644 index 0000000..12ab462 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/StaffApiMiddleware.php @@ -0,0 +1,28 @@ +user(); + + if (!$user || !$user->hasAnyRole([ + 'super_admin', + 'admin', + 'staff', + ])) { + return response()->json([ + 'success' => false, + 'message' => 'شما اجازه دسترسی به این بخش را ندارید.', + ], 403); + } + + return $next($request); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Models/Shipment.php b/04_Laravel/app/Models/Shipment.php index b353f1c..44ab6e2 100644 --- a/04_Laravel/app/Models/Shipment.php +++ b/04_Laravel/app/Models/Shipment.php @@ -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) diff --git a/04_Laravel/app/Models/ShipmentReview.php b/04_Laravel/app/Models/ShipmentReview.php new file mode 100644 index 0000000..667a530 --- /dev/null +++ b/04_Laravel/app/Models/ShipmentReview.php @@ -0,0 +1,54 @@ + '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'); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Services/PriceCalculatorService.php b/04_Laravel/app/Services/PriceCalculatorService.php index 459d52b..f231f8f 100644 --- a/04_Laravel/app/Services/PriceCalculatorService.php +++ b/04_Laravel/app/Services/PriceCalculatorService.php @@ -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; } -} +} \ No newline at end of file diff --git a/04_Laravel/app/Services/ShipmentReviewService.php b/04_Laravel/app/Services/ShipmentReviewService.php new file mode 100644 index 0000000..a1bc64a --- /dev/null +++ b/04_Laravel/app/Services/ShipmentReviewService.php @@ -0,0 +1,179 @@ +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; + } +} \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_09_24_145614_repair_shipment_status_histories_table.php b/04_Laravel/database/migrations/2026_09_24_145614_repair_shipment_status_histories_table.php new file mode 100644 index 0000000..cb1643b --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_24_145614_repair_shipment_status_histories_table.php @@ -0,0 +1,42 @@ +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'); + }); + } + } +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_09_24_150455_add_review_state_to_shipments_table.php b/04_Laravel/database/migrations/2026_09_24_150455_add_review_state_to_shipments_table.php new file mode 100644 index 0000000..5bb5f33 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_24_150455_add_review_state_to_shipments_table.php @@ -0,0 +1,50 @@ +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'); + }); + } +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_09_24_152000_create_shipment_reviews_table.php b/04_Laravel/database/migrations/2026_09_24_152000_create_shipment_reviews_table.php new file mode 100644 index 0000000..b6aa519 --- /dev/null +++ b/04_Laravel/database/migrations/2026_09_24_152000_create_shipment_reviews_table.php @@ -0,0 +1,44 @@ +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'); + } +}; \ No newline at end of file diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php index 1c0e1b4..511eb68 100644 --- a/04_Laravel/routes/api.php +++ b/04_Laravel/routes/api.php @@ -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 (عمومی - برای شبیه‌سازی درگاه) diff --git a/IFNEX Logistics.md b/IFNEX Logistics.md new file mode 100644 index 0000000..bf7b1fb --- /dev/null +++ b/IFNEX Logistics.md @@ -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.