ifnex/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
Kazem Alghasi b9ede16613 refactor(core): harden production environment and fix discount logic
Implement critical security hardening and resolve a P0 bug in the
discount code consumption flow. This update transitions the system
from a development-heavy configuration to a production-ready state.

Key changes:
- Fix P0 bug where `consumeDiscountCode()` was missing, breaking order
  registration; replaced with a robust `DiscountCodeService` using
  `discount_code_usages` for audit trails and idempotency.
- Implement strict production guards for the Zarinpal payment gateway
  to prevent accidental mock processing in live environments.
- Refactor WordPress bridge asset management to use `filemtime` based
  versioning instead of `time()` to prevent unnecessary cache busting.
- Remove debug-only hacks (`opcache_reset`, `header` cache control)
  from the WordPress plugin.
- Enhance file upload security by enforcing strict `mimetypes` validation.
- Update `.gitignore` to exclude sensitive WordPress configuration
  files and local PHPUnit caches.
- Add comprehensive documentation for production deployment, including
  security checklists and key rotation procedures.
- Update test suite to reflect new architecture and ensure 100%
  coverage of the discount service.
2026-10-31 19:18:58 +03:30

1177 lines
50 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;
use App\Services\DiscountCodeService;
use App\Exceptions\DiscountCodeUnavailableException;
class CustomerOrderController extends Controller
{
public function __construct(
protected PriceCalculatorService $calculator,
protected DiscountCodeService $discountCodeService
) {}
/**
* لیست سفارشات کاربر
* 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'],
]);
}
}
// مصرف کد تخفیف (سخت‌سازی Production) — داخل همین transaction
// شکست مصرف = rollback سفارش تا با قیمت تخفیف‌دارِ نامعتبر ثبت نشود
$this->discountCodeService->consumeForOrder(
code: $priceResult['discount_code'] ?? null,
user: $user,
shipment: $shipment,
discountedAmount: (float) ($priceResult['discount_amount'] ?? 0)
);
return $shipment;
});
return response()->json([
'success' => true,
'message' => 'سفارش شما با موفقیت ثبت شد. پس از تأیید کارمند، گزینه پرداخت برای شما فعال خواهد شد.',
'data' => $this->formatShipment($shipment->load(['fromCountry', 'toCountry', 'items'])),
'pricing' => $priceResult,
], 201);
} catch (DiscountCodeUnavailableException $e) {
// کد تخفیف بین محاسبهٔ قیمت و ثبت منقضی/پر شد — 422 تا کاربر با قیمت تازه ثبت کند
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
} 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'],
]);
}
}
// نکته (سخت‌سازی Production): کد تخفیف فقط هنگام ثبت اولیهٔ سفارش مصرف
// می‌شود؛ در ارسال مجدد همان سفارش، مصرف دوباره رخ نمی‌دهد (Idempotency).
// قیمت‌گذاری مجدد با وضعیت فعلی کد انجام می‌شود.
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);
}
}