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.
281 lines
9.6 KiB
PHP
281 lines
9.6 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\ShipmentType;
|
||
use App\Models\Country;
|
||
use App\Models\DiscountCode;
|
||
use App\Models\SystemSetting;
|
||
use InvalidArgumentException;
|
||
|
||
class PriceCalculatorService
|
||
{
|
||
/**
|
||
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی.
|
||
*
|
||
* این متد هیچ side effectای روی دیتابیس ندارد.
|
||
*/
|
||
public function calculate(array $data): array
|
||
{
|
||
// 1. استخراج و نرمالسازی دادههای ورودی
|
||
$type = ShipmentType::from($data['type']);
|
||
$direction = $data['direction'] === 'Outbound' ? 'export' : 'import';
|
||
$chargeableWeight = max($data['weight'], $data['volumetric_weight']);
|
||
|
||
// 2. یافتن کشور و zone مربوطه
|
||
$country = Country::where('iso_code', $data['country_iso'])->firstOrFail();
|
||
|
||
$zone = match (true) {
|
||
$type === ShipmentType::Parcel && $direction === 'export' => $country->export_zone_parcel,
|
||
$type === ShipmentType::Parcel && $direction === 'import' => $country->import_zone_parcel,
|
||
$type !== ShipmentType::Parcel && $direction === 'export' => $country->export_zone_doc,
|
||
$type !== ShipmentType::Parcel && $direction === 'import' => $country->import_zone_doc,
|
||
};
|
||
|
||
// 3. یافتن نرخ پایه
|
||
$baseRate = $this->lookupRate($direction, $type, $chargeableWeight, $zone);
|
||
|
||
// 4. محاسبات مالی
|
||
$financials = $this->calculateFinancials($baseRate);
|
||
|
||
// 5. هزینههای جانبی
|
||
$extraCosts = $this->calculateExtraCosts($data);
|
||
|
||
// 6. قیمت قبل از تخفیف
|
||
$subtotal = $financials['net_rial'] + array_sum($extraCosts);
|
||
|
||
// 7. اعمال تخفیف
|
||
// مهم: اینجا دیگر used_count تغییر نمیکند.
|
||
$discountResult = $this->applyDiscount(
|
||
$subtotal,
|
||
$data['discount_code'] ?? null
|
||
);
|
||
|
||
// 8. محاسبات مالی نهایی
|
||
$vatRate = (float) SystemSetting::get('vat_rate', 0.09);
|
||
$finalPrice = $discountResult['final_price'];
|
||
$vatAmount = $finalPrice * $vatRate;
|
||
$totalFee = $finalPrice + $vatAmount;
|
||
|
||
// 9. خروجی
|
||
return [
|
||
'base_price' => $baseRate,
|
||
'net_dirham' => $financials['net_dirham'],
|
||
'net_rial' => $financials['net_rial'],
|
||
'extra_service' => $extraCosts['extra_service'],
|
||
'packing_cost' => $extraCosts['packing_cost'],
|
||
'domestic_pickup' => $extraCosts['domestic_pickup'],
|
||
'domestic_delivery' => $extraCosts['domestic_delivery'],
|
||
'warehousing_cost' => $extraCosts['warehousing_cost'],
|
||
'discount_applied' => $discountResult['success'],
|
||
'discount_code' => $discountResult['discount_code'] ?? null,
|
||
'discount_amount' => $discountResult['discount_amount'],
|
||
'discount_message' => $discountResult['message'],
|
||
'vat_amount' => $vatAmount,
|
||
'total_fee' => $totalFee,
|
||
'zone' => $zone,
|
||
'chargeable_weight' => $chargeableWeight,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* ثبت مصرف واقعی یک کد تخفیف.
|
||
*
|
||
* این متد باید فقط بعد از محاسبه موفق و داخل transaction
|
||
* ثبت سفارش فراخوانی شود.
|
||
*/
|
||
public function consumeDiscountCode(?string $code): void
|
||
{
|
||
if (empty($code)) {
|
||
return;
|
||
}
|
||
|
||
$discount = DiscountCode::query()
|
||
->where('code', $code)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if (!$discount) {
|
||
throw new InvalidArgumentException('کد تخفیف معتبر نیست.');
|
||
}
|
||
|
||
if (!$discount->is_active) {
|
||
throw new InvalidArgumentException('کد تخفیف غیرفعال است.');
|
||
}
|
||
|
||
if ($discount->expires_at && $discount->expires_at->isPast()) {
|
||
throw new InvalidArgumentException('کد تخفیف منقضی شده است.');
|
||
}
|
||
|
||
if (
|
||
$discount->usage_limit !== null &&
|
||
$discount->used_count >= $discount->usage_limit
|
||
) {
|
||
throw new InvalidArgumentException('ظرفیت استفاده از این کد تخفیف به پایان رسیده است.');
|
||
}
|
||
|
||
$discount->increment('used_count');
|
||
}
|
||
|
||
/**
|
||
* محاسبه ارزش مالی نرخ پایه.
|
||
*/
|
||
private function calculateFinancials(float $baseRate): array
|
||
{
|
||
$profitMargin = (float) SystemSetting::get('profit_margin', 1.25);
|
||
$aedToIrr = (float) SystemSetting::get('aed_to_irr', 455000);
|
||
|
||
$netDirham = $baseRate * $profitMargin;
|
||
$netRial = $netDirham * $aedToIrr;
|
||
|
||
return [
|
||
'net_dirham' => $netDirham,
|
||
'net_rial' => $netRial,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* محاسبه مجموع هزینههای جانبی.
|
||
*/
|
||
private function calculateExtraCosts(array $data): array
|
||
{
|
||
$packingCostDefault = (float) SystemSetting::get(
|
||
'packing_cost_default',
|
||
100000
|
||
);
|
||
|
||
return [
|
||
'extra_service' => (float) ($data['extra_service'] ?? 0),
|
||
'packing_cost' => (float) ($data['packing_cost'] ?? $packingCostDefault),
|
||
'domestic_pickup' => (float) ($data['domestic_pickup'] ?? 0),
|
||
'domestic_delivery' => (float) ($data['domestic_delivery'] ?? 0),
|
||
'warehousing_cost' => (float) ($data['warehousing_cost'] ?? 0),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* اعمال کد تخفیف روی قیمت.
|
||
*
|
||
* این متد pure است و used_count را تغییر نمیدهد.
|
||
*/
|
||
public function applyDiscount(float $totalPrice, ?string $code): array
|
||
{
|
||
if (empty($code)) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'کد تخفیف وارد نشده است.',
|
||
'discount_amount' => 0,
|
||
'final_price' => $totalPrice,
|
||
];
|
||
}
|
||
|
||
$discount = DiscountCode::where('code', $code)->first();
|
||
|
||
// 1. بررسی وجود
|
||
if (!$discount) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'کد تخفیف نامعتبر است.',
|
||
'discount_amount' => 0,
|
||
'final_price' => $totalPrice,
|
||
];
|
||
}
|
||
|
||
// 2. بررسی فعال بودن
|
||
if (!$discount->is_active) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'این کد تخفیف غیرفعال شده است.',
|
||
'discount_amount' => 0,
|
||
'final_price' => $totalPrice,
|
||
];
|
||
}
|
||
|
||
// 3. بررسی تاریخ انقضا
|
||
if ($discount->expires_at && $discount->expires_at->isPast()) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'این کد تخفیف منقضی شده است.',
|
||
'discount_amount' => 0,
|
||
'final_price' => $totalPrice,
|
||
];
|
||
}
|
||
|
||
// 4. حداقل مبلغ سفارش
|
||
if (
|
||
$discount->min_order_amount > 0 &&
|
||
$totalPrice < $discount->min_order_amount
|
||
) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'حداقل مبلغ سفارش برای استفاده از این کد '
|
||
. number_format($discount->min_order_amount)
|
||
. ' ریال است.',
|
||
'discount_amount' => 0,
|
||
'final_price' => $totalPrice,
|
||
];
|
||
}
|
||
|
||
// 5. محدودیت تعداد استفاده
|
||
if (
|
||
$discount->usage_limit &&
|
||
$discount->used_count >= $discount->usage_limit
|
||
) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'ظرفیت استفاده از این کد تخفیف به پایان رسیده است.',
|
||
'discount_amount' => 0,
|
||
'final_price' => $totalPrice,
|
||
];
|
||
}
|
||
|
||
// محاسبه تخفیف
|
||
$discountAmount = 0;
|
||
|
||
if ($discount->type === 'percentage') {
|
||
$discountAmount = ($totalPrice * $discount->value) / 100;
|
||
} elseif ($discount->type === 'fixed') {
|
||
$discountAmount = $discount->value;
|
||
}
|
||
|
||
// جلوگیری از تخفیف بیشتر از مبلغ سفارش
|
||
$discountAmount = min($discountAmount, $totalPrice);
|
||
$finalPrice = $totalPrice - $discountAmount;
|
||
|
||
return [
|
||
'success' => true,
|
||
'message' => 'کد تخفیف با موفقیت اعمال شد.',
|
||
'discount_amount' => $discountAmount,
|
||
'final_price' => $finalPrice,
|
||
'discount_code' => $discount->code,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* جستجوی نرخ حمل و نقل در دیتابیس.
|
||
*/
|
||
private function lookupRate(
|
||
string $direction,
|
||
ShipmentType $type,
|
||
float $weight,
|
||
int $zone
|
||
): float {
|
||
$zoneColumn = 'zone_' . $zone;
|
||
|
||
$rate = \App\Models\ShippingRate::query()
|
||
->where('direction', $direction)
|
||
->where('type', $type->value)
|
||
->where('weight', '<=', $weight)
|
||
->where($zoneColumn, '>', 0)
|
||
->orderByDesc('weight')
|
||
->value($zoneColumn);
|
||
|
||
if ($rate === null) {
|
||
throw new InvalidArgumentException(
|
||
'No rate found for the given parameters.'
|
||
);
|
||
}
|
||
|
||
return (float) $rate;
|
||
}
|
||
} |