ifnex/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
Kazem Alghasi 1c8fb2c9d9 - Add shipment_packages migration and ShipmentPackage model
- Add packages() relation to Shipment model
- Update CustomerOrderController to accept packages[] array
- Auto-calculate volumetric weight from dimensions (L*W*H/5000)
- Redesign order form Step 1 with Multi-Package UI
- Add package repeater (add/remove packages)
- Real-time summary of total weights
- Both real weight and dimensions are required
- Dimensions normalized to * format (5*6*9)

Phase 3.5.1 — Multi-Package complete"
2026-08-29 07:09:25 +03:30

655 lines
27 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\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::PendingPayment,
// وزن
'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_number' => $index + 1,
'weight' => $pkg['weight'],
'volumetric_weight' => $volWeight,
'chargeable_weight' => $chargeable,
'dimensions' => $pkg['dimensions'] ?? null,
'description' => $pkg['description'] ?? null,
]);
}
} else {
// حالت قدیمی: یه بسته با وزن کلی سفارش
ShipmentPackage::create([
'shipment_id' => $shipment->id,
'package_number' => 1,
'weight' => $validated['weight'],
'volumetric_weight' => $validated['volumetric_weight'] ?? 0,
'chargeable_weight' => $validated['chargeable_weight'],
'dimensions' => $validated['dimensions'] ?? null,
'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'],
]);
}
}
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', 'items', 'trackingEvents']);
return response()->json([
'success' => true,
'data' => $this->formatShipment($shipment, detailed: true),
]);
}
/**
* لغو سفارش (فقط در وضعیت 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']);
// آمار سفارشات
$stats = [
'total_orders' => Shipment::where('user_id', $user->id)->count(),
'pending_orders' => Shipment::where('user_id', $user->id)
->where('status', ShipmentStatus::PendingPayment)
->count(),
'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::PendingPayment)
->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,
'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(),
],
'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) {
$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['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::PendingPayment) {
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::PendingPayment) {
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);
}
}