ifnex/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
Kazem Alghasi 61a2643dd0 feat(api): implement shipment review and resubmission workflow
Introduces a complete shipment review system allowing staff to request
changes to customer orders and customers to resubmit corrected orders.

- Add `ReviewState` enum and `ShipmentReview` model to track review history.
- Implement `ShipmentReviewService` to handle approval and change
  request logic.
- Add `resubmit` endpoint for customers to update orders when
  `changes_requested` state is active.
- Add `request-changes` endpoint for staff to flag orders for correction.
- Update `ShipmentResource` in Filament to display review states and
  manage approvals.
- Implement WordPress bridge support for fetching and resubmitting
  orders via AJAX.
- Add database migrations for `shipment_reviews` table and `review_state`
  column on shipments.
- Add `StaffApiMiddleware` to secure staff-specific API routes.
2026-09-27 01:00:26 +03:30

1055 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Api\Customer;
use App\Http\Controllers\Controller;
use App\Models\Shipment;
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;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CustomerOrderController extends Controller
{
public function __construct(
protected PriceCalculatorService $calculator
) {}
/**
* لیست سفارشات کاربر
* GET /api/v1/customer/orders
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'status' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
]);
$user = Auth::user();
$query = Shipment::query()
->where('user_id', $user->id)
->with(['fromCountry', 'toCountry', 'items'])
->orderBy('created_at', 'desc');
if (!empty($validated['status'])) {
$query->where('status', $validated['status']);
}
$shipments = $query->paginate($validated['per_page'] ?? 15);
return response()->json([
'success' => true,
'data' => $shipments->map(function ($shipment) {
return $this->formatShipment($shipment);
}),
'pagination' => [
'current_page' => $shipments->currentPage(),
'last_page' => $shipments->lastPage(),
'per_page' => $shipments->perPage(),
'total' => $shipments->total(),
],
]);
}
/**
* ثبت سفارش جدید
* POST /api/v1/customer/orders
*/
public function store(Request $request): JsonResponse
{
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'],
// وزن و ابعاد (فیلدهای کلی سفارش - برای backward compatibility)
'weight' => ['nullable', 'numeric', 'min:0.1'],
'volumetric_weight' => ['nullable', 'numeric', 'min:0'],
'dimensions' => ['nullable', 'string', 'max:100'],
// بسته‌ها (Multi-Package) — حداقل ۱، حداکثر ۱۰ بسته
'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);
}
$user = Auth::user();
// بررسی اینکه کاربر کیف پول فعال دارد
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;
}
// Override وزن کلی سفارش
$validated['weight'] = $totalWeight;
$validated['volumetric_weight'] = $totalVolumetricWeight;
$validated['chargeable_weight'] = max($totalWeight, $totalVolumetricWeight);
} else {
// حالت قدیمی: یه بسته
$validated['chargeable_weight'] = max(
(float) $validated['weight'],
(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);
}
// ساخت شماره AWB (فرمت: IFN-YYYY-XXXXX)
$awbNo = $this->generateAwbNumber();
// ذخیره سفارش
try {
$shipment = DB::transaction(function () use ($validated, $user, $priceResult, $awbNo) {
$chargeableWeight = max(
(float) $validated['weight'],
(float) ($validated['volumetric_weight'] ?? 0)
);
$shipment = Shipment::create([
'user_id' => $user->id,
'awb_no' => $awbNo,
'direction' => $validated['direction'],
'type' => $validated['type'],
'status' => ShipmentStatus::PendingApproval,
// وزن
'weight' => $validated['weight'],
'volumetric_weight' => $validated['volumetric_weight'] ?? null,
'chargeable_weight' => $chargeableWeight,
'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,
]);
// ذخیره بسته‌ها (Multi-Package)
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
);
return $shipment;
});
return response()->json([
'success' => true,
'message' => 'سفارش شما با موفقیت ثبت شد. پس از تأیید کارمند، گزینه پرداخت برای شما فعال خواهد شد.',
'data' => $this->formatShipment($shipment->load(['fromCountry', 'toCountry', 'items'])),
'pricing' => $priceResult,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'خطا در ثبت سفارش: ' . $e->getMessage(),
], 500);
}
}
/**
* جزئیات یک سفارش
* GET /api/v1/customer/orders/{shipment}
*/
public function show(Shipment $shipment): JsonResponse
{
$user = Auth::user();
// بررسی مالکیت
if ($shipment->user_id !== $user->id) {
return response()->json([
'success' => false,
'message' => 'شما به این سفارش دسترسی ندارید.',
], 403);
}
$shipment->load([
'fromCountry',
'toCountry',
'packages',
'items',
'trackingEvents',
'reviews',
]);
return response()->json([
'success' => true,
'data' => $this->formatShipment($shipment, detailed: true),
]);
}
/**
* ارسال مجدد سفارش پس از درخواست اصلاح
* 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
*/
public function cancel(Shipment $shipment): JsonResponse
{
$user = Auth::user();
if ($shipment->user_id !== $user->id) {
return response()->json([
'success' => false,
'message' => 'شما به این سفارش دسترسی ندارید.',
], 403);
}
if (!$shipment->status->canBeCancelledByCustomer()) {
return response()->json([
'success' => false,
'message' => 'این سفارش در وضعیتی نیست که بتوان آن را لغو کرد.',
], 400);
}
$shipment->update(['status' => ShipmentStatus::Cancelled]);
return response()->json([
'success' => true,
'message' => 'سفارش با موفقیت لغو شد.',
'data' => $this->formatShipment($shipment->fresh()),
]);
}
/**
* پروفایل و آمار کاربر
* GET /api/v1/customer/profile
*/
public function profile(Request $request): JsonResponse
{
$user = Auth::user();
$user->load(['wallet']);
// محاسبه مجموع سفارشات در انتظار پرداخت (بدهی کاربر)
$pending_payments_total = Shipment::where('user_id', $user->id)
->where('status', ShipmentStatus::Approved)
->sum('total_fee');
// مانده حساب = موجودی کیف پول - بدهی‌ها
$wallet_balance = $user->wallet ? $user->wallet->balance : 0;
$account_balance = $wallet_balance - $pending_payments_total;
// آمار سفارشات
$stats = [
'total_orders' => Shipment::where('user_id', $user->id)->count(),
'pending_approval' => Shipment::where('user_id', $user->id)
->where('status', ShipmentStatus::PendingApproval)
->count(),
'pending_payment' => Shipment::where('user_id', $user->id)
->where('status', ShipmentStatus::Approved)
->count(),
'pending_payment_total' => $pending_payments_total,
'active_orders' => Shipment::where('user_id', $user->id)
->whereIn('status', [
ShipmentStatus::Processed,
ShipmentStatus::PickedUp,
ShipmentStatus::InTransit,
ShipmentStatus::OutForDelivery,
])
->count(),
'delivered_orders' => Shipment::where('user_id', $user->id)
->where('status', ShipmentStatus::Delivered)
->count(),
'total_spent' => Shipment::where('user_id', $user->id)
->where('status', '!=', ShipmentStatus::PendingApproval)
->where('status', '!=', ShipmentStatus::Approved)
->where('status', '!=', ShipmentStatus::Cancelled)
->sum('total_fee'),
];
return response()->json([
'success' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'member_since' => $user->created_at->format('Y-m-d'),
],
'wallet' => $user->wallet ? [
'balance' => $user->wallet->balance,
'is_frozen' => $user->wallet->is_frozen,
] : null,
'account_balance' => $account_balance,
'pending_payments_total' => $pending_payments_total,
'stats' => $stats,
]);
}
/**
* لیست کشورها برای فرم
* GET /api/v1/customer/countries
*/
public function countries(): JsonResponse
{
$countries = Country::select('id', 'name', 'iso_code', 'export_zone_parcel', 'import_zone_parcel')
->where('is_active', true)
->orderBy('name')
->get();
return response()->json([
'success' => true,
'data' => $countries,
]);
}
/**
* فرمت کردن Shipment برای خروجی API
*/
private function formatShipment(Shipment $shipment, bool $detailed = false): array
{
$data = [
'id' => $shipment->id,
'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(),
'is_paid' => $shipment->status?->isPaid(),
'can_cancel' => $shipment->status?->canBeCancelledByCustomer(),
'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,
'net_rial' => $shipment->net_rial,
'net_dirham' => $shipment->net_dirham,
'created_at' => $shipment->created_at->toIso8601String(),
'created_at_jalali' => $this->toJalali($shipment->created_at),
];
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,
'phone' => $shipment->sender_phone,
'email' => $shipment->sender_email,
'address' => $shipment->sender_address,
'city' => $shipment->sender_city,
];
$data['receiver'] = [
'name' => $shipment->receiver_name,
'company' => $shipment->receiver_company,
'phone' => $shipment->receiver_phone,
'email' => $shipment->receiver_email,
'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,
'packing_cost' => $shipment->packing_cost,
'domestic_pickup' => $shipment->domestic_pickup,
'domestic_delivery' => $shipment->domestic_delivery,
'warehousing_cost' => $shipment->warehousing_cost,
'vat_amount' => $shipment->vat_amount,
'discount' => $shipment->discount,
'total_fee' => $shipment->total_fee,
];
$data['items'] = $shipment->items->map(fn($item) => [
'row' => $item->row_number,
'description' => $item->description,
'hs_code' => $item->hs_code,
'quantity' => $item->quantity,
'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,
'location' => $event->location,
]);
}
return $data;
}
/**
* تولید شماره AWB
*/
private function generateAwbNumber(): string
{
do {
$number = 'IFN-' . date('Y') . '-' . str_pad(random_int(10000, 99999), 5, '0', STR_PAD_LEFT);
} while (Shipment::where('awb_no', $number)->exists());
return $number;
}
/**
* تبدیل تاریخ به شمسی
*/
private function toJalali($date): string
{
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');
}
}
/**
* پرداخت سفارش از کیف پول
* POST /api/v1/customer/orders/{shipment}/pay-wallet
*/
public function payFromWallet(Shipment $shipment): JsonResponse
{
$user = Auth::user();
// بررسی مالکیت
if ($shipment->user_id !== $user->id) {
return response()->json([
'success' => false,
'message' => 'شما به این سفارش دسترسی ندارید.',
], 403);
}
// بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند)
if ($shipment->status !== ShipmentStatus::Approved) {
return response()->json([
'success' => false,
'message' => 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.',
], 400);
}
$wallet = $user->wallet;
if (!$wallet) {
return response()->json([
'success' => false,
'message' => 'کیف پول شما وجود ندارد.',
], 400);
}
$paymentService = app(\App\Services\OrderPaymentService::class);
$result = $paymentService->payFromWallet($shipment, $wallet);
if ($result['success']) {
return response()->json([
'success' => true,
'message' => $result['message'],
'data' => $this->formatShipment($shipment->fresh()->load(['fromCountry', 'toCountry'])),
]);
}
return response()->json($result, 400);
}
/**
* شروع پرداخت از درگاه
* POST /api/v1/customer/orders/{shipment}/pay-gateway
*/
public function payViaGateway(Request $request, Shipment $shipment): JsonResponse
{
$user = Auth::user();
// بررسی مالکیت
if ($shipment->user_id !== $user->id) {
return response()->json([
'success' => false,
'message' => 'شما به این سفارش دسترسی ندارید.',
], 403);
}
// بررسی وضعیت (فقط سفارشات تأیید شده قابل پرداخت هستند)
if ($shipment->status !== ShipmentStatus::Approved) {
return response()->json([
'success' => false,
'message' => 'این سفارش هنوز تأیید نشده است و قابل پرداخت نیست.',
], 400);
}
$validated = $request->validate([
'frontend_callback' => ['required', 'url', 'max:500'],
]);
$wallet = $user->wallet;
if (!$wallet) {
return response()->json([
'success' => false,
'message' => 'کیف پول شما وجود ندارد.',
], 400);
}
$paymentService = app(\App\Services\OrderPaymentService::class);
$result = $paymentService->initiateGatewayPayment($shipment, $wallet);
if ($result['success']) {
// ذخیره callback URL در metadata تراکنش
$transaction = WalletTransaction::find($result['transaction_id']);
if ($transaction) {
$metadata = $transaction->metadata ?? [];
$metadata['frontend_callback'] = $validated['frontend_callback'];
$transaction->update(['metadata' => $metadata]);
}
return response()->json([
'success' => true,
'payment_url' => $result['payment_url'],
'transaction_id' => $result['transaction_id'],
]);
}
return response()->json($result, 400);
}
}