ifnex/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
Kazem Alghasi 7fe368bc46 feat(invoices): thousand-separator formatting + fix total calc + integrate invoices into customer portal
Polish the manual invoice feature with 4 improvements:

1. Admin invoice form: numeric fields (unit_price, discount_amount) now
   display values with thousand-separators (e.g. 1,000,000 instead of
   1000000) via formatStateUsing/dehydrateStateUsing for easier reading
   of large Rial amounts. The placeholder/subtotal/total calculations
   also strip commas before arithmetic.

2. Fix 'total amount payable = 0' bug in the form. The placeholder
   had a typo: 'foreach ( as )' overwrote the loop variable
   and made the inner calculation use the array instead of each item,
   so the sum was always 0. Now correctly iterates with .

3. Customer dashboard widget: add an 'Invoices pending payment' card
   showing the count, with a link to /my-account/?tab=invoices if > 0.
   Relabel the existing 'Pending payment' card to 'Pending payment
   (orders)' to clarify it's for orders, not invoices.

4. Customer profile financial status: include manual invoices in the
   accounting_balance and pending_payments_total. Show a breakdown
   list (orders vs invoices) under the balance card, plus two new stat
   cards (pending invoices count/total, paid invoices count/total)
   with a link to the invoices tab.
2026-10-29 23:59:19 +03:30

1162 lines
49 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\Models\ManualInvoice;
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_state' => ['nullable', 'string', 'max:255'],
'sender_city' => ['nullable', 'string', 'max:255'],
'sender_postal_code' => ['nullable', 'string', 'max:20'],
'sender_national_id' => ['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_state' => ['nullable', 'string', 'max:255'],
'receiver_city' => ['nullable', 'string', 'max:255'],
'receiver_postal_code' => ['nullable', 'string', 'max:20'],
'receiver_national_id' => ['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'],
// اطلاعات مالی (از محاسبه) — همگی به ریال (IRR)
'shipping_price' => $priceResult['base_price'], // قیمت پایه به ریال
'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_rial' => $priceResult['net_rial'],
// NOTE: فیلد net_dirham دیگه استفاده نمی‌شه — سیستم بر مبنای ریال کار می‌کنه
// فرستنده
'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_state' => $validated['sender_state'] ?? null,
'sender_city' => $validated['sender_city'] ?? null,
'sender_postal_code' => $validated['sender_postal_code'] ?? null,
'sender_national_id' => $validated['sender_national_id'] ?? 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_state' => $validated['receiver_state'] ?? null,
'receiver_city' => $validated['receiver_city'] ?? null,
'receiver_postal_code' => $validated['receiver_postal_code'] ?? null,
'receiver_national_id' => $validated['receiver_national_id'] ?? 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_state' => ['nullable', 'string', 'max:255'],
'sender_city' => ['nullable', 'string', 'max:255'],
'sender_postal_code' => ['nullable', 'string', 'max:20'],
'sender_national_id' => ['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_state' => ['nullable', 'string', 'max:255'],
'receiver_city' => ['nullable', 'string', 'max:255'],
'receiver_postal_code' => ['nullable', 'string', 'max:20'],
'receiver_national_id' => ['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['base_price'], // قیمت پایه به ریال
'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_rial' => $priceResult['net_rial'],
// NOTE: فیلد net_dirham دیگه استفاده نمی‌شه — سیستم بر مبنای ریال کار می‌کنه
'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_state' => $validated['sender_state'] ?? null,
'sender_city' => $validated['sender_city'] ?? null,
'sender_postal_code' => $validated['sender_postal_code'] ?? null,
'sender_national_id' => $validated['sender_national_id'] ?? 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_state' => $validated['receiver_state'] ?? null,
'receiver_city' => $validated['receiver_city'] ?? null,
'receiver_postal_code' => $validated['receiver_postal_code'] ?? null,
'receiver_national_id' => $validated['receiver_national_id'] ?? 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');
// محاسبه مجموع فاکتورهای دستی در انتظار پرداخت
$pending_invoices_total = ManualInvoice::where('user_id', $user->id)
->where('status', 'pending_payment')
->sum('total_amount');
$pending_invoices_count = ManualInvoice::where('user_id', $user->id)
->where('status', 'pending_payment')
->count();
// کل بدهی = بدهی سفارشات + بدهی فاکتورهای دستی
$total_pending_payments = $pending_payments_total + $pending_invoices_total;
// مانده حساب = موجودی کیف پول - کل بدهی‌ها
$wallet_balance = $user->wallet ? $user->wallet->balance : 0;
$account_balance = $wallet_balance - $total_pending_payments;
// بدهی‌های چندارزی (EUR/USD/AED/CNY) — به همان ارز خارجی، نه معادل ریالی
$foreign_credits = app(\App\Services\CustomerCreditService::class)
->getCustomerDebtsByCurrency($user);
// آمار سفارشات
$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,
// ─── فاکتورهای دستی ───
'pending_invoices_count' => $pending_invoices_count,
'pending_invoices_total' => $pending_invoices_total,
'paid_invoices_count' => ManualInvoice::where('user_id', $user->id)
->where('status', 'paid')
->count(),
'paid_invoices_total' => ManualInvoice::where('user_id', $user->id)
->where('status', 'paid')
->sum('total_amount'),
// ─── پایان فاکتورهای دستی ───
'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' => $total_pending_payments,
// تفکیک برای نمایش در پورتال
'pending_payments_breakdown' => [
'orders' => $pending_payments_total,
'invoices' => $pending_invoices_total,
'total' => $total_pending_payments,
],
'pending_invoices_count' => $pending_invoices_count,
'foreign_credits' => $foreign_credits,
'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,
]);
}
/**
* دریافت نوتیفیکیشن‌های کاربر
* GET /api/v1/customer/notifications
*/
public function notifications(Request $request): JsonResponse
{
$user = $request->user();
$perPage = $request->integer('per_page', 20);
$notifications = $user->notifications()
->orderBy('created_at', 'desc')
->paginate($perPage);
return response()->json([
'success' => true,
'data' => array_map(fn ($n) => [
'id' => $n->id,
'type' => $n->type,
'data' => $n->data,
'read_at' => $n->read_at,
'created_at' => $n->created_at?->toIso8601String(),
], $notifications->items()),
'unread_count' => $user->unreadNotifications()->count(),
'pagination' => [
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'per_page' => $notifications->perPage(),
'total' => $notifications->total(),
],
]);
}
/**
* علامت‌گذاری نوتیفیکیشن به‌عنوان خوانده‌شده
* POST /api/v1/customer/notifications/{notification}/read
*/
public function markNotificationRead(Request $request, $notification): JsonResponse
{
$user = $request->user();
$notif = $user->notifications()->where('id', $notification)->first();
if (!$notif) {
return response()->json([
'success' => false,
'message' => 'اعلان یافت نشد.',
], 404);
}
$notif->markAsRead();
return response()->json([
'success' => true,
'message' => 'اعلان به‌عنوان خوانده‌شده علامت‌گذاری شد.',
]);
}
/**
* فرمت کردن 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,
'shipping_price' => $shipment->shipping_price, // قیمت پایه به ریال
'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,
'state' => $shipment->sender_state,
'city' => $shipment->sender_city,
'postal_code' => $shipment->sender_postal_code,
'national_id' => $shipment->sender_national_id,
];
$data['receiver'] = [
'name' => $shipment->receiver_name,
'company' => $shipment->receiver_company,
'phone' => $shipment->receiver_phone,
'email' => $shipment->receiver_email,
'address' => $shipment->receiver_address,
'state' => $shipment->receiver_state,
'city' => $shipment->receiver_city,
'postal_code' => $shipment->receiver_postal_code,
'national_id' => $shipment->receiver_national_id,
];
$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);
}
}