ifnex/04_Laravel/app/Services/PriceCalculatorService.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

316 lines
19 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\Services;
use App\Models\Country;
use App\Models\CountryZone;
use App\Models\DiscountCode;
use App\Models\RateEntry;
use App\Models\SystemSetting;
use App\Enums\ShipmentDirection;
/**
* PriceCalculatorService
* ----------------------
* موتور قیمت‌گذاری ۴-رُبعی:
*
* ┌──────────┬──────────────┬──────────────────┐
* │ │ DOCUMENT │ NON-DOCUMENT │
* ├──────────┼──────────────┼──────────────────┤
* │ Export │ Export×Doc │ Export×Parcel │
* │ Import │ Import×Doc │ Import×Parcel │
* └──────────┴──────────────┴──────────────────┘
*
* ─── معماری ارزی (Currency Architecture) ───
* ارز مبنا در کل سیستم IFNEX = ریال ایران (IRR).
* همه نرخ‌ها در جدول rate_entries به‌صورت ریال ذخیره می‌شوند
* (تبدیل در زمان ایمپورت توسط RateImportService انجام می‌شود).
*
* بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد — نرخ مستقیماً به ریال است.
*
* جریان محاسبه:
* 1. نرمال‌سازی ورودی (direction / type) و تعیین کشور مقصد با ISO.
* 2. جستجوی زون کشور از جدول country_zones.
* 3. جستجوی نرخ ریال از جدول rate_entries (نزدیک‌ترین وزن بالاتر).
* 4. اعمال ضریب سود + هزینه‌های جانبی (همگی به ریال) → ناخالص ریال.
* 5. محاسبه مالیات بر ارزش افزوده (۹٪) و کسر تخفیف (در صورت وجود کد).
*
* امضای متد calculate(): آرایه‌ای از ورودی‌ها را می‌گیرد و آرایه‌ای کامل
* از فیلدهای نمایشی برمی‌گرداند که وردپرس منتظرش است.
*/
class PriceCalculatorService
{
public function __construct(
protected ExchangeRateService $exchangeRate,
) {}
/**
* محاسبه قیمت نهایی محموله — همه مبالغ به ریال.
*
* @param array $input {
* @var string $direction 'export' | 'import' (یا 'Outbound' | 'Inbound')
* @var string $type 'DOC_NORMAL' | 'DOC_ECONOMY' | 'PARCEL'
* @var string $country_iso کد ISO دو حرفی کشور مقصد
* @var float $weight وزن واقعی (کیلوگرم)
* @var float $volumetric_weight وزن حجمی (کیلوگرم)
* @var float|null $extra_service هزینه سرویس اضافی (ریال)
* @var float|null $packing_cost هزینه بسته‌بندی (ریال)
* @var float|null $domestic_pickup هزینه جمع‌آوری داخلی (ریال)
* @var float|null $domestic_delivery هزینه تحویل داخلی (ریال)
* @var float|null $warehousing_cost هزینه انبارداری (ریال)
* @var string|null $discount_code کد تخفیف اختیاری
* }
*
* @return array {
* @var float $base_price قیمت پایه (ریال)
* @var int $net_rial ناخالص شامل سود + هزینه‌ها (ریال)
* @var float $packing_cost هزینه بسته‌بندی (ریال)
* @var int $vat_amount مبلغ مالیات (ریال)
* @var bool $discount_applied آیا تخفیف اعمال شد؟
* @var int $discount_amount مبلغ تخفیف (ریال)
* @var int $total_fee مبلغ نهایی قابل پرداخت (ریال)
* @var float $extra_service هزینه سرویس اضافی (ریال)
* @var float $domestic_pickup هزینه جمع‌آوری (ریال)
* @var float $domestic_delivery هزینه تحویل (ریال)
* @var float $warehousing_cost هزینه انبارداری (ریال)
* @var float $weight وزن واقعی
* @var float $volumetric_weight وزن حجمی
* @var float $chargeable_weight وزن قابل محاسبه
* @var int $zone زون محاسبه‌شده
* @var string $service_type 'document' | 'parcel'
* @var string $direction 'export' | 'import'
* @var string $country نام کشور
* @var string $country_iso ISO کشور
* @var int $rate_entry_id آیدی رکورد نرخ استفاده‌شده
* @var float $profit_margin ضریب سود اعمال‌شده
* @var string $currency همیشه 'IRR'
* }
*
* @throws \InvalidArgumentException ورودی نامعتبر
* @throws \RuntimeException زون یا نرخ یافت نشد
*/
public function calculate(array $input): array
{
// ───────────────────────────────────────────────────────────────
// ۱. نرمال‌سازی direction
// ───────────────────────────────────────────────────────────────
$directionRaw = strtolower((string) ($input['direction'] ?? ''));
$direction = match ($directionRaw) {
'outbound' => 'export',
'inbound' => 'import',
'export', 'import' => $directionRaw,
default => null,
};
if ($direction === null) {
throw new \InvalidArgumentException(
"Invalid direction: " . ($input['direction'] ?? '(empty)') . " — expected 'export'|'import'|'Outbound'|'Inbound'"
);
}
// ───────────────────────────────────────────────────────────────
// ۲. نرمال‌سازی type → service_type
// ───────────────────────────────────────────────────────────────
$typeRaw = strtoupper(trim((string) ($input['type'] ?? '')));
$serviceType = match ($typeRaw) {
'DOC_NORMAL', 'DOC_ECONOMY', 'DOCUMENT', 'DOC', 'DOCNORMAL', 'DOCECONOMY' => 'document',
'PARCEL', 'NON DOC', 'NON-DOC', 'NONDOC', 'NON_DOCUMENT' => 'parcel',
default => null,
};
if ($serviceType === null) {
throw new \InvalidArgumentException(
"Invalid type: " . ($input['type'] ?? '(empty)') . " — expected DOC_NORMAL|DOC_ECONOMY|PARCEL"
);
}
// ───────────────────────────────────────────────────────────────
// ۳. حل کشور با ISO → نام
// ───────────────────────────────────────────────────────────────
$countryIso = strtoupper(trim((string) ($input['country_iso'] ?? '')));
if (strlen($countryIso) !== 2) {
throw new \InvalidArgumentException(
"Invalid country_iso: must be exactly 2 letters, got: " . ($input['country_iso'] ?? '(empty)')
);
}
$country = Country::where('iso_code', $countryIso)->first();
if (! $country) {
throw new \RuntimeException("Country not found for ISO: {$countryIso}");
}
$countryName = $country->name;
// ───────────────────────────────────────────────────────────────
// ۴. اوزان
// ───────────────────────────────────────────────────────────────
$weight = (float) ($input['weight'] ?? 0);
$volumetricWeight = (float) ($input['volumetric_weight'] ?? 0);
$chargeableWeight = max($weight, $volumetricWeight);
// ───────────────────────────────────────────────────────────────
// ۵. جستجوی زون (برای کشور × نوع سرویس)
// ───────────────────────────────────────────────────────────────
$zone = CountryZone::lookupZone($countryName, $serviceType);
if ($zone === null) {
throw new \RuntimeException(
"No zone mapping for country '{$countryName}' service type '{$serviceType}'. "
. "Please import the Zone sheet via admin → Rate Management."
);
}
// ───────────────────────────────────────────────────────────────
// ۶. جستجوی نرخ — مستقیماً به ریال (IRR)
// ───────────────────────────────────────────────────────────────
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'IRR');
if (! $rateEntry) {
throw new \RuntimeException(
"No rate found for {$direction}×{$serviceType} weight={$chargeableWeight}kg zone={$zone}. "
. "Please import rates via admin → Rate Management."
);
}
// ───────────────────────────────────────────────────────────────
// ۷. قیمت پایه — مستقیماً به ریال (نیازی به تبدیل نیست)
// ───────────────────────────────────────────────────────────────
$basePriceIrr = (float) $rateEntry->rate;
// ───────────────────────────────────────────────────────────────
// ۸. هزینه‌های جانبی — همه به ریال
// ───────────────────────────────────────────────────────────────
$extras = [
'extra_service' => (float) ($input['extra_service'] ?? 0),
'packing_cost' => (float) ($input['packing_cost'] ?? 0),
'domestic_pickup' => (float) ($input['domestic_pickup'] ?? 0),
'domestic_delivery' => (float) ($input['domestic_delivery'] ?? 0),
'warehousing_cost' => (float) ($input['warehousing_cost'] ?? 0),
];
$extrasTotalIrr = array_sum($extras);
// ───────────────────────────────────────────────────────────────
// ۹. اعمال ضریب سود (روی قیمت پایه، نه روی هزینه‌های جانبی)
// ───────────────────────────────────────────────────────────────
$profitMargin = (float) SystemSetting::get(
'profit_margin',
config('ifnex.financial_defaults.profit_margin', 1.25)
);
if ($profitMargin <= 0) {
$profitMargin = 1.0;
}
// ناخالص = (قیمت پایه × ضریب سود) + هزینه‌های جانبی — همه به ریال
$netRial = ($basePriceIrr * $profitMargin) + $extrasTotalIrr;
// ───────────────────────────────────────────────────────────────
// ۱۰. مالیات بر ارزش افزوده
// ───────────────────────────────────────────────────────────────
$vatRate = (float) SystemSetting::get(
'vat_rate',
config('ifnex.financial_defaults.vat_rate', 0.09)
);
$vatAmount = $netRial * $vatRate;
// ───────────────────────────────────────────────────────────────
// ۱۱. تخفیف (در صورت وجود کد)
// ───────────────────────────────────────────────────────────────
$discountApplied = false;
$discountAmount = 0.0;
$discountCode = trim((string) ($input['discount_code'] ?? ''));
if ($discountCode !== '') {
$discount = DiscountCode::where('code', $discountCode)
->where('is_active', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
// سخت‌سازی Production: کد‌هایی که ظرفیتشان پر شده دیگر در قیمت‌گذاری اعمال نمی‌شوند
->where(function ($q) {
$q->whereNull('usage_limit')
->orWhereColumn('used_count', '<', 'usage_limit');
})
->first();
if ($discount) {
$subtotal = $netRial + $vatAmount;
$minOrder = (float) ($discount->min_order_amount ?? 0);
if ($minOrder > 0 && $subtotal < $minOrder) {
// زیر سقف — تخفیف اعمال نمی‌شود
} else {
$discountApplied = true;
if ($discount->type === 'percentage') {
$discountAmount = $subtotal * ((float) $discount->value / 100);
} else {
$discountAmount = min((float) $discount->value, $subtotal);
}
}
}
}
// ───────────────────────────────────────────────────────────────
// ۱۲. مبلغ نهایی (گردش به نزدیک‌ترین ۱۰۰۰ ریال)
// ───────────────────────────────────────────────────────────────
$totalFee = $netRial + $vatAmount - $discountAmount;
$totalFee = (int) round($totalFee / 1000) * 1000;
return [
// فیلدهای نمایشی — همه به ریال
'base_price' => round($basePriceIrr, 2),
'net_rial' => (int) round($netRial),
'packing_cost' => round($extras['packing_cost'], 2),
'vat_amount' => (int) round($vatAmount),
'discount_applied' => $discountApplied,
'discount_amount' => (int) round($discountAmount),
'total_fee' => $totalFee,
// تفکیک هزینه‌ها (ریال)
'extra_service' => round($extras['extra_service'], 2),
'domestic_pickup' => round($extras['domestic_pickup'], 2),
'domestic_delivery' => round($extras['domestic_delivery'], 2),
'warehousing_cost' => round($extras['warehousing_cost'], 2),
// اوزان
'weight' => round($weight, 3),
'volumetric_weight' => round($volumetricWeight, 3),
'chargeable_weight' => round($chargeableWeight, 3),
// متادیتای جستجو
'zone' => $zone,
'service_type' => $serviceType,
'direction' => $direction,
'country' => $countryName,
'country_iso' => $countryIso,
'rate_entry_id' => $rateEntry->id,
'profit_margin' => $profitMargin,
'currency' => 'IRR',
];
}
/**
* راحتی: محاسبه قیمت از روی مدل Shipment.
*
* @param \App\Models\Shipment $shipment
* @return array
*/
public function calculateForShipment($shipment): array
{
$direction = $shipment->direction?->value ?? (string) $shipment->direction;
$type = $shipment->type?->value ?? (string) $shipment->type;
// کشور مقصد: برای صادرات = toCountry، برای واردات = fromCountry
$country = ($direction === 'export')
? $shipment->toCountry
: $shipment->fromCountry;
return $this->calculate([
'direction' => $direction,
'type' => $type,
'country_iso' => $country?->iso_code,
'weight' => (float) ($shipment->weight ?? 0),
'volumetric_weight' => (float) ($shipment->volumetric_weight ?? 0),
'extra_service' => (float) ($shipment->extra_service ?? 0),
'packing_cost' => (float) ($shipment->packing_cost ?? 0),
'domestic_pickup' => (float) ($shipment->domestic_pickup ?? 0),
'domestic_delivery' => (float) ($shipment->domestic_delivery ?? 0),
'warehousing_cost' => (float) ($shipment->warehousing_cost ?? 0),
]);
}
}