@@ -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.