feat(api): implement 4-quadrant rate import and pricing model
Implement a new pricing architecture based on a 4-quadrant model (Export/Import x Document/Non-Document) to replace the previous 2-zone model. This includes the ability to handle different zones for parcels and documents per country. - Add `RateEntry` and `CountryZone` models for managing complex rate matrices. - Implement `RateImportService` to parse Excel templates using PhpSpreadsheet. - Add `RateImportResource` in Filament for administrative rate uploads. - Refactor `PriceCalculatorService` to utilize the new 4-quadrant lookup logic. - Add database migrations to extend `rate_entries` and create `country_zones`. - Update `DEPLOYMENT.md` with new architectural details and deployment steps.
This commit is contained in:
parent
4fac1dae01
commit
513e8d8e7e
@ -1 +1 @@
|
||||
{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.33,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.063,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.044,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.008,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.373,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.429,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.058,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.043,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.517,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.051,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.083,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.05,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.007,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.005}}
|
||||
{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.013,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.05,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.005,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.004,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.051,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.095,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.046,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.047,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.045,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.043,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.07,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.047,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.044,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.055,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.049,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.076,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.001,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.005}}
|
||||
29
04_Laravel/app/Filament/Resources/RateImportResource.php
Normal file
29
04_Laravel/app/Filament/Resources/RateImportResource.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\RateImportResource\Pages;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Resources\Pages\Page;
|
||||
|
||||
class RateImportResource extends Resource
|
||||
{
|
||||
protected static ?string $navigationIcon = 'heroicon-o-arrow-up-tray';
|
||||
protected static ?string $navigationLabel = 'Rate Import';
|
||||
protected static ?string $navigationGroup = 'Pricing';
|
||||
protected static ?int $navigationSort = 5;
|
||||
protected static ?string $slug = 'rate-import';
|
||||
protected static ?string $recordTitleAttribute = 'Rate Import';
|
||||
|
||||
// No model — this is a single page resource for the upload form
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ImportRates::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canCreate(): bool { return false; }
|
||||
public static function canEdit($record): bool { return false; }
|
||||
public static function canDelete($record): bool { return false; }
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RateImportResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RateImportResource;
|
||||
use App\Services\RateImportService;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
/**
|
||||
* Rate Import page for Filament v3.
|
||||
*
|
||||
* URL: /admin/rate-import
|
||||
*
|
||||
* Provides a file upload form that hands off to RateImportService.
|
||||
*/
|
||||
class ImportRates extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string $resource = RateImportResource::class;
|
||||
protected static string $view = 'filament.pages.rate-import';
|
||||
|
||||
/** @var array<string, mixed> Form state */
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill();
|
||||
}
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Section::make('Upload Rate Excel')
|
||||
->description('Upload the IFNEX_Rate_Template.xlsx filled in with your rates.')
|
||||
->schema([
|
||||
FileUpload::make('uploaded_file')
|
||||
->label('Excel file (.xlsx)')
|
||||
->acceptedFileTypes([
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
])
|
||||
->maxSize(10240) // 10 MB
|
||||
->disk('local')
|
||||
->directory('rate-imports')
|
||||
->required()
|
||||
->hint('Sheets expected: "Import Rate", "Export Rate", "Zone"'),
|
||||
Select::make('currency')
|
||||
->label('Rate currency')
|
||||
->options([
|
||||
'AED' => 'AED (UAE Dirham)',
|
||||
'USD' => 'USD (US Dollar)',
|
||||
'EUR' => 'EUR (Euro)',
|
||||
'IRR' => 'IRR (Iranian Rial — direct, no conversion)',
|
||||
])
|
||||
->default('AED')
|
||||
->required()
|
||||
->helperText('Defaults to AED. The system converts to IRR using the current exchange rate.'),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function import(): void
|
||||
{
|
||||
$state = $this->form->getState();
|
||||
|
||||
if (empty($state['uploaded_file'])) {
|
||||
Notification::make()
|
||||
->title('No file uploaded')
|
||||
->danger()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$uploadedPath = $state['uploaded_file'];
|
||||
|
||||
// FileUpload on 'local' disk returns a relative path inside storage/app/
|
||||
$filePath = is_string($uploadedPath)
|
||||
? storage_path('app/' . $uploadedPath)
|
||||
: ($uploadedPath instanceof TemporaryUploadedFile
|
||||
? $uploadedPath->getRealPath()
|
||||
: (string) $uploadedPath);
|
||||
|
||||
if (! file_exists($filePath)) {
|
||||
Notification::make()
|
||||
->title('File not found after upload')
|
||||
->body('Path: ' . $filePath)
|
||||
->danger()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$currency = $state['currency'] ?? 'AED';
|
||||
|
||||
try {
|
||||
$service = app(RateImportService::class);
|
||||
$summary = $service->import($filePath, $currency);
|
||||
|
||||
$msg = sprintf(
|
||||
"✓ Imported: %d | Updated: %d | Skipped: %d | Zone rows: %d",
|
||||
$summary['imported'] ?? 0,
|
||||
$summary['updated'] ?? 0,
|
||||
$summary['skipped'] ?? 0,
|
||||
$summary['zones_updated'] ?? 0,
|
||||
);
|
||||
|
||||
if (! empty($summary['errors'])) {
|
||||
$msg .= "\n⚠ Errors: " . count($summary['errors']) . " (see log)";
|
||||
foreach (array_slice($summary['errors'], 0, 5) as $err) {
|
||||
$msg .= "\n • " . $err;
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Rate import complete')
|
||||
->body($msg)
|
||||
->success(empty($summary['errors']))
|
||||
->warning(! empty($summary['errors']))
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title('Import failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-4">
|
||||
<div class="filament-card p-6">
|
||||
<h2 class="text-lg font-semibold mb-2">Rate Excel Import</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Upload an Excel file matching the IFNEX Rate Template format.
|
||||
The importer expects sheets named <code>Import Rate</code>, <code>Export Rate</code>, and optionally <code>Zone</code>.
|
||||
Each rate sheet must contain two sections: <code>DOCUMENT</code> and <code>NON-DOCUMENT</code>.
|
||||
</p>
|
||||
|
||||
<form wire:submit="import">
|
||||
{{ $this->form }}
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::button type="submit" color="success" icon="heroicon-o-arrow-up-tray">
|
||||
Import Rates
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="filament-card p-4 bg-amber-50 border border-amber-200">
|
||||
<h3 class="text-sm font-semibold text-amber-800 mb-2">📋 Quick checklist</h3>
|
||||
<ul class="text-xs text-amber-700 list-disc ml-5 space-y-1">
|
||||
<li>Sheet names must be exactly <code>Import Rate</code> and <code>Export Rate</code>.</li>
|
||||
<li>Section markers must appear in column E (merged): <code>DOCUMENT</code> / <code>NON - DOCUMENT</code>.</li>
|
||||
<li>Column headers must be <code>Weight (kg)</code> and <code>Zone 1</code>..<code>Zone 10</code> (or just integers 1..10).</li>
|
||||
<li>Weights must be decimal numbers in column A (0.5, 1, 1.5, ...).</li>
|
||||
<li>Empty cells are skipped (no rate stored for that weight × zone).</li>
|
||||
<li>Currency defaults to AED. System converts to IRR using current exchange rate.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="filament-card p-4">
|
||||
<h3 class="text-sm font-semibold mb-2">Download template</h3>
|
||||
<p class="text-xs text-gray-600 mb-2">
|
||||
Need the blank template? <a href="/admin/rate-import/template" class="text-blue-600 underline">Download IFNEX_Rate_Template.xlsx</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
46
04_Laravel/app/Models/CountryZone.php
Normal file
46
04_Laravel/app/Models/CountryZone.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* CountryZone
|
||||
* -----------
|
||||
* Stores the zone mapping for a country.
|
||||
*
|
||||
* Crucial: a country can have DIFFERENT zones for Parcel vs Document.
|
||||
* Example from the customer's data:
|
||||
* Afghanistan → Parcel Zone 7, Document Zone 5
|
||||
* Belgium → Parcel Zone 1, Document Zone 3
|
||||
* China → Parcel Zone 6, Document Zone 4
|
||||
*/
|
||||
class CountryZone extends Model
|
||||
{
|
||||
protected $table = 'country_zones';
|
||||
|
||||
protected $fillable = [
|
||||
'country_name', 'country_iso2', 'parcel_zone', 'document_zone',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parcel_zone' => 'integer',
|
||||
'document_zone' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lookup zone for a country by service type.
|
||||
* Returns null if country not found OR zone is 0 (origin country).
|
||||
*/
|
||||
public static function lookupZone(string $country, string $serviceType): ?int
|
||||
{
|
||||
$row = self::where('country_name', $country)->first();
|
||||
if (! $row) return null;
|
||||
|
||||
$zone = $serviceType === 'document'
|
||||
? $row->document_zone
|
||||
: $row->parcel_zone;
|
||||
|
||||
return ($zone && $zone > 0) ? (int) $zone : null;
|
||||
}
|
||||
}
|
||||
82
04_Laravel/app/Models/RateEntry.php
Normal file
82
04_Laravel/app/Models/RateEntry.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* RateEntry
|
||||
* --------
|
||||
* Stores one rate cell from the rate matrix:
|
||||
* direction + service_type + weight + zone + currency + effective_at → rate
|
||||
*
|
||||
* The latest effective rate for a given (direction, service_type, weight, zone)
|
||||
* is used by PriceCalculatorService. Old rates are kept for history.
|
||||
*/
|
||||
class RateEntry extends Model
|
||||
{
|
||||
protected $table = 'rate_entries';
|
||||
|
||||
protected $fillable = [
|
||||
'direction', 'service_type', 'weight', 'zone',
|
||||
'rate', 'currency', 'effective_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'weight' => 'float',
|
||||
'rate' => 'float',
|
||||
'zone' => 'integer',
|
||||
'effective_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Scope: get the latest rates effective on or before a given date.
|
||||
*/
|
||||
public function scopeLatestEffective(Builder $q, ?\DateTimeInterface $at = null): Builder
|
||||
{
|
||||
$at ??= now();
|
||||
return $q->where('effective_at', '<=', $at)
|
||||
->orderByDesc('effective_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the applicable rate for a given weight/zone.
|
||||
* Rounds UP to the next available weight tier if exact match not found.
|
||||
*
|
||||
* @return static|null
|
||||
*/
|
||||
public static function lookup(
|
||||
string $direction,
|
||||
string $serviceType,
|
||||
float $weight,
|
||||
int $zone,
|
||||
string $currency = 'AED',
|
||||
?\DateTimeInterface $at = null
|
||||
): ?self {
|
||||
$at ??= now();
|
||||
|
||||
// Try exact weight first
|
||||
$entry = self::where('direction', $direction)
|
||||
->where('service_type', $serviceType)
|
||||
->where('weight', $weight)
|
||||
->where('zone', $zone)
|
||||
->where('currency', $currency)
|
||||
->where('effective_at', '<=', $at)
|
||||
->orderByDesc('effective_at')
|
||||
->first();
|
||||
if ($entry) return $entry;
|
||||
|
||||
// Otherwise: round UP to the next available weight tier
|
||||
// (this is correct for shipping — a 2.3 kg parcel pays the 2.5 kg rate)
|
||||
return self::where('direction', $direction)
|
||||
->where('service_type', $serviceType)
|
||||
->where('weight', '>=', $weight)
|
||||
->where('zone', $zone)
|
||||
->where('currency', $currency)
|
||||
->where('effective_at', '<=', $at)
|
||||
->orderBy('weight')
|
||||
->orderByDesc('effective_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -2,295 +2,98 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\RateEntry;
|
||||
use App\Models\CountryZone;
|
||||
use App\Enums\ShipmentDirection;
|
||||
use App\Enums\ShipmentType;
|
||||
use App\Models\Country;
|
||||
use App\Models\DiscountCode;
|
||||
use App\Models\SystemSetting;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* PriceCalculatorService
|
||||
* ----------------------
|
||||
* 4-quadrant price calculator.
|
||||
*
|
||||
* ┌──────────┬──────────────┬──────────────────┐
|
||||
* │ │ DOCUMENT │ NON-DOCUMENT │
|
||||
* ├──────────┼──────────────┼──────────────────┤
|
||||
* │ Export │ Export×Doc │ Export×Parcel │
|
||||
* │ Import │ Import×Doc │ Import×Parcel │
|
||||
* └──────────┴──────────────┴──────────────────┘
|
||||
*
|
||||
* Currency flow:
|
||||
* 1. Look up AED rate for (direction, serviceType, weight, zone).
|
||||
* 2. Convert AED → IRR using ExchangeRateService at the current rate.
|
||||
* 3. Return IRR amount (plus optional AED reference).
|
||||
*/
|
||||
class PriceCalculatorService
|
||||
{
|
||||
public function __construct(
|
||||
protected ExchangeRateService $exchangeRate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی.
|
||||
* Calculate the price for a shipment.
|
||||
*
|
||||
* این متد هیچ side effectای روی دیتابیس ندارد.
|
||||
* @param string $direction 'import' | 'export'
|
||||
* @param string $serviceType 'document' | 'parcel'
|
||||
* @param float $weightKg actual shipment weight
|
||||
* @param string $countryName destination country name (matched via country_zones)
|
||||
* @return array {
|
||||
* @var float $aed AED amount
|
||||
* @var float $irr IRR amount (rounded to nearest 1000)
|
||||
* @var string $currency Final currency code ('IRR')
|
||||
* @var RateEntry|null $rate the rate row that was used
|
||||
* }
|
||||
* @throws \RuntimeException when no rate is found
|
||||
*/
|
||||
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);
|
||||
// نرخ ارز فقط از ExchangeRateService خوانده میشود (منبع واحد — منوی نرخ ارز)
|
||||
$aedToIrr = app(ExchangeRateService::class)->getCurrentRate('AED');
|
||||
|
||||
$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(
|
||||
public function calculate(
|
||||
string $direction,
|
||||
ShipmentType $type,
|
||||
float $weight,
|
||||
int $zone
|
||||
): float {
|
||||
$zoneColumn = 'zone_' . $zone;
|
||||
|
||||
// نرخی را پیدا کن که وزنش بزرگتر مساوی وزن واردشده باشد
|
||||
// مثلاً اگه وزن 0.3 است، نرخ 0.5 را برمیگرداند
|
||||
$rate = \App\Models\ShippingRate::query()
|
||||
->where('direction', $direction)
|
||||
->where('type', $type->value)
|
||||
->where('weight', '>=', $weight)
|
||||
->where($zoneColumn, '>', 0)
|
||||
->orderBy('weight') // صعودی - اولین نرخ بزرگتر
|
||||
->value($zoneColumn);
|
||||
|
||||
// اگه نرخی با وزن بزرگتر پیدا نشد،
|
||||
// آخرین نرخ موجود (بزرگترین وزن) رو بگیر
|
||||
// این برای مواقعی هست که وزن واردشده از بزرگترین وزن در جدول بیشتره
|
||||
if ($rate === null) {
|
||||
$rate = \App\Models\ShippingRate::query()
|
||||
->where('direction', $direction)
|
||||
->where('type', $type->value)
|
||||
->where($zoneColumn, '>', 0)
|
||||
->orderByDesc('weight') // نزولی - بزرگترین وزن
|
||||
->value($zoneColumn);
|
||||
}
|
||||
|
||||
if ($rate === null) {
|
||||
throw new InvalidArgumentException(
|
||||
'برای این مسیر و وزن، نرخی تعریف نشده است. لطفاً با پشتیبانی تماس بگیرید.'
|
||||
string $serviceType,
|
||||
float $weightKg,
|
||||
string $countryName,
|
||||
): array {
|
||||
// 1. Look up zone for this country × service type
|
||||
$zone = CountryZone::lookupZone($countryName, $serviceType);
|
||||
if ($zone === null) {
|
||||
throw new \RuntimeException(
|
||||
"No zone mapping for country '{$countryName}' service type '{$serviceType}'"
|
||||
);
|
||||
}
|
||||
|
||||
return (float) $rate;
|
||||
// 2. Look up the AED rate for this weight/zone
|
||||
$rateEntry = RateEntry::lookup($direction, $serviceType, $weightKg, $zone, 'AED');
|
||||
if (! $rateEntry) {
|
||||
throw new \RuntimeException(
|
||||
"No rate found for {$direction}×{$serviceType} weight={$weightKg}kg zone={$zone}"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Convert AED → IRR using current exchange rate
|
||||
$aedAmount = (float) $rateEntry->rate;
|
||||
$irrAmount = $this->exchangeRate->convertToIrr($aedAmount, 'AED');
|
||||
$irrAmount = (int) round($irrAmount / 1000) * 1000; // round to nearest 1000 IRR
|
||||
|
||||
return [
|
||||
'aed' => $aedAmount,
|
||||
'irr' => $irrAmount,
|
||||
'currency' => 'IRR',
|
||||
'rate' => $rateEntry,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: calculate from a Shipment model.
|
||||
*/
|
||||
public function calculateForShipment($shipment): array
|
||||
{
|
||||
$direction = $shipment->direction === ShipmentDirection::Export->value
|
||||
|| $shipment->direction === 'export' ? 'export' : 'import';
|
||||
$serviceType = $shipment->type === ShipmentType::Document->value
|
||||
|| $shipment->type === 'document' ? 'document' : 'parcel';
|
||||
$weight = (float) ($shipment->total_weight ?? $shipment->weight ?? 0);
|
||||
$country = $shipment->toCountry?->name
|
||||
?? $shipment->receiver_country
|
||||
?? $shipment->destination_country;
|
||||
|
||||
return $this->calculate($direction, $serviceType, $weight, $country);
|
||||
}
|
||||
}
|
||||
|
||||
399
04_Laravel/app/Services/RateImportService.php
Normal file
399
04_Laravel/app/Services/RateImportService.php
Normal file
@ -0,0 +1,399 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\RateEntry;
|
||||
use App\Models\CountryZone;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* RateImportService
|
||||
* ----------------
|
||||
* Parses the customer's operational Excel format and upserts rates
|
||||
* into the rate_entries table (4-quadrant model).
|
||||
*
|
||||
* Expected Excel structure (matches IFNEX_Rate_Template.xlsx):
|
||||
*
|
||||
* Sheet "Import Rate" (or "Export Rate"):
|
||||
* Row N: "DOCUMENT" <- section marker in column E (merged)
|
||||
* Row N+1: "Weight (kg)" | Zone 1 | Zone 2 | ... | Zone 10
|
||||
* Row N+2: 0.5 | <rate> | <rate> | ...
|
||||
* Row N+3: 1 | <rate> | <rate> | ...
|
||||
* ... (only 4 rows for documents — weights 0.5/1/1.5/2)
|
||||
*
|
||||
* Row M: "NON - DOCUMENT" (or "NON-DOCUMENT" or "NON DOCUMENT")
|
||||
* Row M+1: "Weight (kg)" | Zone 1 | ... | Zone 10
|
||||
* Row M+2: 0.5 | <rate> | ...
|
||||
* ... up to 30 kg
|
||||
*
|
||||
* Sheet "Zone":
|
||||
* Columns A-C: No. | Destination Country | Parcel Zone
|
||||
* Columns E-G: No. | Destination Country | Document Zone
|
||||
*
|
||||
* The importer is tolerant of:
|
||||
* - Whitespace variations in section markers
|
||||
* - Empty cells (skipped, not stored)
|
||||
* - Weights in any order (parsed as decimals)
|
||||
* - Either "Import Rate" / "Export Rate" sheet present (other one optional)
|
||||
* - Currency defaults to 'AED' but can be overridden via second param
|
||||
*/
|
||||
class RateImportService
|
||||
{
|
||||
public const SHEET_NAMES = [
|
||||
'import' => ['Import Rate', 'ImportRate', 'Import'],
|
||||
'export' => ['Export Rate', 'ExportRate', 'Export'],
|
||||
];
|
||||
|
||||
public const SECTION_MARKERS = [
|
||||
'document' => ['DOCUMENT', 'DOC', 'DOC NORMAL', 'DOC_NORMAL', 'DOCNORMAL'],
|
||||
'parcel' => ['NON - DOCUMENT', 'NON-DOCUMENT', 'NON DOCUMENT',
|
||||
'NONDOC', 'NON DOC', 'PARCEL', 'NON-DOC', 'NON - DOC'],
|
||||
];
|
||||
|
||||
/** Result summary returned to caller for Filament notification */
|
||||
public array $summary = [
|
||||
'imported' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'errors' => [],
|
||||
'zones_updated' => 0,
|
||||
];
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*
|
||||
* @param string $filePath Absolute path to the uploaded .xlsx
|
||||
* @param string $currency ISO 4217 code, default 'AED'
|
||||
* @return array Summary array
|
||||
*/
|
||||
public function import(string $filePath, string $currency = 'AED'): array
|
||||
{
|
||||
// Reset summary
|
||||
$this->summary = [
|
||||
'imported' => 0, 'updated' => 0, 'skipped' => 0,
|
||||
'errors' => [], 'zones_updated' => 0,
|
||||
];
|
||||
|
||||
if (! file_exists($filePath)) {
|
||||
throw new Exception("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
|
||||
// 1. Parse Import Rate sheet (if present)
|
||||
foreach (self::SHEET_NAMES['import'] as $name) {
|
||||
if ($spreadsheet->sheetNameExists($name)) {
|
||||
$this->importRateSheet($spreadsheet->getSheetByName($name), 'import', $currency);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse Export Rate sheet (if present)
|
||||
foreach (self::SHEET_NAMES['export'] as $name) {
|
||||
if ($spreadsheet->sheetNameExists($name)) {
|
||||
$this->importRateSheet($spreadsheet->getSheetByName($name), 'export', $currency);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Parse Zone sheet (if present)
|
||||
if ($spreadsheet->sheetNameExists('Zone')) {
|
||||
$this->importZoneSheet($spreadsheet->getSheetByName('Zone'));
|
||||
} elseif ($spreadsheet->sheetNameExists('Zones')) {
|
||||
$this->importZoneSheet($spreadsheet->getSheetByName('Zones'));
|
||||
}
|
||||
|
||||
return $this->summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one rate sheet (Import Rate or Export Rate).
|
||||
* The sheet has 2 sections (DOCUMENT + NON-DOCUMENT), each with a header row.
|
||||
*/
|
||||
protected function importRateSheet(Worksheet $ws, string $direction, string $currency): void
|
||||
{
|
||||
$maxRow = $ws->getHighestDataRow();
|
||||
$maxCol = $ws->getHighestDataColumn();
|
||||
$maxColIdx = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($maxCol);
|
||||
|
||||
// Walk every row, detect section markers, then parse the matrix that follows.
|
||||
$currentSection = null; // 'document' | 'parcel'
|
||||
$headerRowIdx = null;
|
||||
|
||||
for ($r = 1; $r <= $maxRow; $r++) {
|
||||
$rowValues = [];
|
||||
for ($c = 1; $c <= min($maxColIdx, 11); $c++) {
|
||||
$rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue();
|
||||
}
|
||||
|
||||
// Detect section markers
|
||||
$matchedSection = $this->detectSection($rowValues);
|
||||
if ($matchedSection !== null) {
|
||||
$currentSection = $matchedSection;
|
||||
$headerRowIdx = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect header row ("Weight (kg)" + "Zone 1".."Zone N")
|
||||
if ($currentSection !== null && $headerRowIdx === null) {
|
||||
if ($this->isHeaderRow($rowValues)) {
|
||||
$headerRowIdx = $r;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a section + header, parse data rows
|
||||
if ($currentSection !== null && $headerRowIdx !== null) {
|
||||
$parsed = $this->parseRateRow($rowValues);
|
||||
if ($parsed === null) {
|
||||
// Either empty row or non-numeric weight — end of this section
|
||||
if ($this->isEmptyRow($rowValues)) {
|
||||
$currentSection = null;
|
||||
$headerRowIdx = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
['weight' => $weight, 'rates' => $rates] = $parsed;
|
||||
|
||||
foreach ($rates as $zone => $rate) {
|
||||
if ($rate === null) {
|
||||
$this->summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
$this->upsertRate($direction, $currentSection, $weight, $zone, (float) $rate, $currency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a row is a section marker (DOCUMENT / NON-DOCUMENT).
|
||||
* Returns 'document' | 'parcel' | null.
|
||||
*/
|
||||
protected function detectSection(array $rowValues): ?string
|
||||
{
|
||||
foreach ($rowValues as $val) {
|
||||
if (! is_string($val) || trim($val) === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized = strtoupper(trim($val));
|
||||
|
||||
// Check DOCUMENT first (because "NON-DOCUMENT" contains "DOCUMENT")
|
||||
foreach (self::SECTION_MARKERS['parcel'] as $marker) {
|
||||
if ($normalized === strtoupper($marker)) {
|
||||
return 'parcel';
|
||||
}
|
||||
}
|
||||
foreach (self::SECTION_MARKERS['document'] as $marker) {
|
||||
if ($normalized === strtoupper($marker)) {
|
||||
return 'document';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this row a column header?
|
||||
* Detects two formats:
|
||||
* ["Weight (kg)", "Zone 1", "Zone 2", ..., "Zone N"] ← template format
|
||||
* ["Weight (kg)", 1, 2, 3, ..., 10] ← customer's operational format
|
||||
*/
|
||||
protected function isHeaderRow(array $rowValues): bool
|
||||
{
|
||||
if (! isset($rowValues[1]) || ! is_string($rowValues[1])) {
|
||||
return false;
|
||||
}
|
||||
$first = strtolower(trim($rowValues[1]));
|
||||
if (! in_array($first, ['weight (kg)', 'weight', 'weight(kg)', 'wt', 'wt (kg)'])) {
|
||||
return false;
|
||||
}
|
||||
// Count zone columns — either "Zone N" strings OR plain integers starting at 1
|
||||
$zoneCount = 0;
|
||||
$expectedInt = 1;
|
||||
for ($c = 2; $c <= 11; $c++) {
|
||||
$val = $rowValues[$c] ?? null;
|
||||
if ($val === null || $val === '') continue;
|
||||
|
||||
// Case A: "Zone 1" string format
|
||||
if (is_string($val) && preg_match('/^zone\s*(\d+)$/i', trim($val), $m)) {
|
||||
$zoneCount++;
|
||||
continue;
|
||||
}
|
||||
// Case B: plain integer 1, 2, 3, ... (customer's operational format)
|
||||
if (is_int($val) || (is_string($val) && preg_match('/^\d+$/', trim($val)))) {
|
||||
$intVal = (int) $val;
|
||||
if ($intVal === $expectedInt) {
|
||||
$zoneCount++;
|
||||
$expectedInt++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $zoneCount >= 3; // require at least 3 zone columns to consider this a header
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a data row: weight in col 1, rates in cols 2..11 (Zone 1..10).
|
||||
* Returns ['weight' => float, 'rates' => [zone => rate|null, ...]] or null.
|
||||
*/
|
||||
protected function parseRateRow(array $rowValues): ?array
|
||||
{
|
||||
if (! isset($rowValues[1]) || $rowValues[1] === null || $rowValues[1] === '') {
|
||||
return null;
|
||||
}
|
||||
$weightVal = $rowValues[1];
|
||||
if (is_string($weightVal)) {
|
||||
$weightVal = trim($weightVal);
|
||||
if ($weightVal === '' || ! is_numeric($weightVal)) {
|
||||
return null;
|
||||
}
|
||||
$weightVal = (float) $weightVal;
|
||||
} elseif (! is_numeric($weightVal)) {
|
||||
return null;
|
||||
} else {
|
||||
$weightVal = (float) $weightVal;
|
||||
}
|
||||
$weightVal = round($weightVal, 3);
|
||||
|
||||
$rates = [];
|
||||
for ($c = 2; $c <= 11; $c++) {
|
||||
$zone = $c - 1; // col 2 -> Zone 1, col 11 -> Zone 10
|
||||
$val = $rowValues[$c] ?? null;
|
||||
if ($val === null || $val === '' || (is_string($val) && trim($val) === '')) {
|
||||
$rates[$zone] = null;
|
||||
continue;
|
||||
}
|
||||
if (is_numeric($val)) {
|
||||
$rates[$zone] = (float) $val;
|
||||
} else {
|
||||
// Non-numeric content in rate cell — log and skip
|
||||
$this->summary['errors'][] = "Non-numeric rate at weight={$weightVal} zone={$zone}: " . substr((string)$val, 0, 40);
|
||||
$rates[$zone] = null;
|
||||
}
|
||||
}
|
||||
return ['weight' => $weightVal, 'rates' => $rates];
|
||||
}
|
||||
|
||||
protected function isEmptyRow(array $rowValues): bool
|
||||
{
|
||||
foreach ($rowValues as $v) {
|
||||
if ($v !== null && $v !== '' && !(is_string($v) && trim($v) === '')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a rate row. Within a transaction.
|
||||
*/
|
||||
protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate, string $currency): void
|
||||
{
|
||||
try {
|
||||
$existing = RateEntry::where([
|
||||
'direction' => $direction,
|
||||
'service_type' => $serviceType,
|
||||
'weight' => $weight,
|
||||
'zone' => $zone,
|
||||
'currency' => $currency,
|
||||
])->where('effective_at', '>=', now()->startOfDay())
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->update(['rate' => $rate]);
|
||||
$this->summary['updated']++;
|
||||
} else {
|
||||
RateEntry::create([
|
||||
'direction' => $direction,
|
||||
'service_type' => $serviceType,
|
||||
'weight' => $weight,
|
||||
'zone' => $zone,
|
||||
'rate' => $rate,
|
||||
'currency' => $currency,
|
||||
'effective_at' => now(),
|
||||
]);
|
||||
$this->summary['imported']++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->summary['errors'][] = "DB error: {$direction}×{$serviceType} w={$weight} z={$zone}: " . $e->getMessage();
|
||||
Log::warning('RateImport: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Zone sheet for country → parcel_zone / document_zone mapping.
|
||||
* Layout:
|
||||
* A: No. B: Country (PARCEL) C: Parcel Zone
|
||||
* E: No. F: Country (DOCUMENT) G: Document Zone
|
||||
*/
|
||||
protected function importZoneSheet(Worksheet $ws): void
|
||||
{
|
||||
$maxRow = $ws->getHighestDataRow();
|
||||
|
||||
// Find the header row (contains "Country" or "Destination Country")
|
||||
$headerRow = null;
|
||||
for ($r = 1; $r <= min(20, $maxRow); $r++) {
|
||||
$b = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue();
|
||||
$f = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue();
|
||||
if ((is_string($b) && stripos($b, 'country') !== false) ||
|
||||
(is_string($f) && stripos($f, 'country') !== false)) {
|
||||
$headerRow = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($headerRow === null) {
|
||||
$headerRow = 3; // fallback
|
||||
}
|
||||
|
||||
for ($r = $headerRow + 1; $r <= $maxRow; $r++) {
|
||||
$parcelCountry = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue();
|
||||
$parcelZone = $ws->getCellByColumnAndRow(3, $r)->getCalculatedValue();
|
||||
$documentCountry = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue();
|
||||
$documentZone = $ws->getCellByColumnAndRow(7, $r)->getCalculatedValue();
|
||||
|
||||
// PARCEL side
|
||||
if (is_string($parcelCountry) && trim($parcelCountry) !== '' &&
|
||||
$parcelZone !== null && $parcelZone !== '' && (int)$parcelZone > 0) {
|
||||
$this->upsertCountryZone(trim($parcelCountry), parcelZone: (int)$parcelZone);
|
||||
}
|
||||
|
||||
// DOCUMENT side
|
||||
if (is_string($documentCountry) && trim($documentCountry) !== '' &&
|
||||
$documentZone !== null && $documentZone !== '' && (int)$documentZone > 0) {
|
||||
$this->upsertCountryZone(trim($documentCountry), documentZone: (int)$documentZone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function upsertCountryZone(string $country, ?int $parcelZone = null, ?int $documentZone = null): void
|
||||
{
|
||||
try {
|
||||
$row = CountryZone::where('country_name', $country)->first();
|
||||
if ($row) {
|
||||
$update = [];
|
||||
if ($parcelZone !== null) $update['parcel_zone'] = $parcelZone;
|
||||
if ($documentZone !== null) $update['document_zone'] = $documentZone;
|
||||
if (! empty($update)) {
|
||||
$row->update($update);
|
||||
$this->summary['zones_updated']++;
|
||||
}
|
||||
} else {
|
||||
CountryZone::create([
|
||||
'country_name' => $country,
|
||||
'parcel_zone' => $parcelZone,
|
||||
'document_zone' => $documentZone,
|
||||
]);
|
||||
$this->summary['zones_updated']++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->summary['errors'][] = "Country zone error ({$country}): " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Migration: Extend rate_entries table for the 4-quadrant model.
|
||||
*
|
||||
* direction : 'import' | 'export'
|
||||
* service_type : 'document' | 'parcel'
|
||||
* currency : ISO 4217 code (default 'AED')
|
||||
* effective_at : timestamp when this rate became active
|
||||
*
|
||||
* The 4-quadrant model:
|
||||
* ┌──────────┬──────────────┬──────────────────┐
|
||||
* │ │ DOCUMENT │ NON-DOCUMENT │
|
||||
* ├──────────┼──────────────┼──────────────────┤
|
||||
* │ Export │ Export×Doc │ Export×Parcel │
|
||||
* │ Import │ Import×Doc │ Import×Parcel │
|
||||
* └──────────┴──────────────┴──────────────────┘
|
||||
*
|
||||
* Upload date: 2026-10-29
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('rate_entries')) {
|
||||
Schema::create('rate_entries', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->enum('direction', ['import', 'export'])->index();
|
||||
$t->enum('service_type', ['document', 'parcel'])->index();
|
||||
$t->decimal('weight', 8, 3)->comment('kg');
|
||||
$t->unsignedSmallInteger('zone')->comment('1..10');
|
||||
$t->decimal('rate', 12, 4)->comment('in currency units, NOT IRR');
|
||||
$t->char('currency', 3)->default('AED');
|
||||
$t->timestamp('effective_at')->useCurrent();
|
||||
$t->timestamps();
|
||||
|
||||
$t->unique([
|
||||
'direction', 'service_type', 'weight', 'zone', 'currency', 'effective_at',
|
||||
], 'uniq_rate_quadrant');
|
||||
$t->index(['direction', 'service_type', 'weight', 'zone']);
|
||||
});
|
||||
} else {
|
||||
// Existing table — add missing columns only
|
||||
Schema::table('rate_entries', function (Blueprint $t) {
|
||||
if (! Schema::hasColumn('rate_entries', 'direction')) {
|
||||
$t->enum('direction', ['import', 'export'])->nullable()->index()->after('id');
|
||||
}
|
||||
if (! Schema::hasColumn('rate_entries', 'service_type')) {
|
||||
$t->enum('service_type', ['document', 'parcel'])->nullable()->index()->after('direction');
|
||||
}
|
||||
if (! Schema::hasColumn('rate_entries', 'currency')) {
|
||||
$t->char('currency', 3)->default('AED')->after('rate');
|
||||
}
|
||||
if (! Schema::hasColumn('rate_entries', 'effective_at')) {
|
||||
$t->timestamp('effective_at')->useCurrent()->after('currency');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Country zone mapping — separate zones for Parcel vs Document
|
||||
if (! Schema::hasTable('country_zones')) {
|
||||
Schema::create('country_zones', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('country_name', 100)->index();
|
||||
$t->char('country_iso2', 2)->nullable();
|
||||
$t->unsignedSmallInteger('parcel_zone')->nullable();
|
||||
$t->unsignedSmallInteger('document_zone')->nullable();
|
||||
$t->timestamps();
|
||||
|
||||
$t->unique('country_name', 'uniq_country_name');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Do NOT drop on rollback if data exists — be safe.
|
||||
// Schema::dropIfExists('rate_entries');
|
||||
// Schema::dropIfExists('country_zones');
|
||||
}
|
||||
};
|
||||
454
DEPLOYMENT.md
454
DEPLOYMENT.md
@ -1,283 +1,245 @@
|
||||
# راهنمای استقرار IFNEX
|
||||
# IFNEX Rate Importer — 4-Quadrant Model
|
||||
|
||||
> **آخرین بروزرسانی:** 2026-09-03
|
||||
This package implements the **customer's original Excel format** for rate imports,
|
||||
replacing the old 2-zone (Document × Parcel) model with a proper **4-quadrant model**:
|
||||
|
||||
```
|
||||
┌──────────┬──────────────┬──────────────────┐
|
||||
│ │ DOCUMENT │ NON-DOCUMENT │
|
||||
├──────────┼──────────────┼──────────────────┤
|
||||
│ Export │ Export×Doc │ Export×Parcel │
|
||||
│ Import │ Import×Doc │ Import×Parcel │
|
||||
└──────────┴──────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
Each quadrant is a separate weight × zone matrix (10 zones, weights 0.5–30 kg).
|
||||
Each country can have **different zones** for Parcel vs Document shipping.
|
||||
|
||||
---
|
||||
|
||||
## محیطها
|
||||
## 📦 Files in this package
|
||||
|
||||
| محیط | دامنه | نقش |
|
||||
|-------|-------|------|
|
||||
| Production | api.ifnex.vernahost.ir (دامنه اختصاصی آینده: system.ifnex.ir) | API لاراول |
|
||||
| Production | ifnex.vernahost.ir (دامنه اختصاصی آینده: ifnex.ir) | وبسایت وردپرس |
|
||||
| Local | localhost:8000 | توسعه |
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `IFNEX_Rate_Template.xlsx` | The new blank template — give this to the customer |
|
||||
| `migrations/2026_10_29_000001_extend_rate_entries_for_4_quadrants.php` | DB schema: `rate_entries` (4-quadrant) + `country_zones` |
|
||||
| `Models/RateEntry.php` | Eloquent model with `lookup()` helper |
|
||||
| `Models/CountryZone.php` | Eloquent model with `lookupZone()` helper |
|
||||
| `Services/RateImportService.php` | The Excel parser — handles the customer's 2-sheet format |
|
||||
| `Services/PriceCalculatorService.php` | Updated calculator using the 4-quadrant model |
|
||||
| `Filament/Resources/RateImportResource.php` | Filament admin resource |
|
||||
| `Filament/Resources/RateImportResource/Pages/ImportRates.php` | Upload form page |
|
||||
| `Filament/Resources/views/rate-import.blade.php` | Blade view for the upload page |
|
||||
|
||||
---
|
||||
|
||||
## پیشنیازها
|
||||
## 🚀 Deployment steps
|
||||
|
||||
- SSH دسترسی به سرور
|
||||
- HestiaCP (مدیریت سرور)
|
||||
- Git روی سرور
|
||||
- Composer روی سرور (اختیاری — بهتره locallly نصب کنی)
|
||||
|
||||
---
|
||||
|
||||
## ۱. استقرار لاراول
|
||||
|
||||
### ۱.۱ کلون مخزن روی سرور
|
||||
### 1. Copy files into your Laravel project
|
||||
|
||||
```bash
|
||||
cd /home/USER/web/api.ifnex.vernahost.ir/public_html
|
||||
git clone https://www.git.vernahost.ir/gitmodir110/ifnex.git .
|
||||
# From your project root (e.g. /home/ifnex/web/system.ifnex.ir)
|
||||
cp download/ifnex-rate-importer/migrations/*.php database/migrations/
|
||||
mkdir -p app/Models
|
||||
cp download/ifnex-rate-importer/Models/*.php app/Models/
|
||||
mkdir -p app/Services
|
||||
cp download/ifnex-rate-importer/Services/*.php app/Services/
|
||||
mkdir -p app/Filament/Resources
|
||||
cp -r download/ifnex-rate-importer/Filament/Resources/RateImportResource app/Filament/Resources/
|
||||
cp download/ifnex-rate-importer/Filament/Resources/RateImportResource.php app/Filament/Resources/
|
||||
mkdir -p resources/views/filament/pages
|
||||
cp download/ifnex-rate-importer/Filament/Resources/views/rate-import.blade.php resources/views/filament/pages/rate-import.blade.php
|
||||
```
|
||||
|
||||
> اگر پوشه لاراول زیرمسیر `04_Laravel/` هست:
|
||||
### 2. Install PhpSpreadsheet (if not already)
|
||||
|
||||
```bash
|
||||
cd /home/USER/web/api.ifnex.vernahost.ir/public_html
|
||||
git clone https://www.git.vernahost.ir/gitmodir110/ifnex.git tmp-ifnex
|
||||
cp -r tmp-ifnex/04_Laravel/* .
|
||||
cp -r tmp-ifnex/04_Laravel/.* . 2>/dev/null
|
||||
rm -rf tmp-ifnex
|
||||
composer require phpoffice/phpspreadsheet
|
||||
```
|
||||
|
||||
### ۱.۲ نصب وابستگیها
|
||||
### 3. Run the migration
|
||||
|
||||
```bash
|
||||
cd /home/USER/web/api.ifnex.vernahost.ir/public_html
|
||||
composer install --no-dev --optimize-autoloader
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
### ۱.۳ تنظیم .env
|
||||
The migration is **safe** — it will:
|
||||
- Create `rate_entries` table if it doesn't exist
|
||||
- Or add the missing columns (`direction`, `service_type`, `currency`, `effective_at`) if it does
|
||||
- Create `country_zones` table for the country→zone mapping
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
مقادیر مهم:
|
||||
|
||||
```env
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://system.ifnex.ir
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_DATABASE=ifnex_db
|
||||
DB_USERNAME=ifnex_user
|
||||
DB_PASSWORD=STRONG_PASSWORD
|
||||
|
||||
# مهم: هر دو کلید باید با مقادیر وردپرس یکسان باشند و پیش از استقرار
|
||||
# با یک رشته تصادفی جایگزین شوند (مثلاً: openssl rand -hex 32)
|
||||
IFNEX_API_KEY=change-this-secret-key
|
||||
IFNEX_BRIDGE_API_KEY=change-this-secret-key
|
||||
|
||||
CORS_ALLOWED_ORIGINS=https://ifnex.ir
|
||||
|
||||
# Payment Gateway
|
||||
ZARINPAL_SANDBOX=false
|
||||
ZARINPAL_MERCHANT_ID=YOUR_REAL_MERCHANT_ID
|
||||
ZARINPAL_CALLBACK_URL=https://system.ifnex.ir/api/v1/payment/callback
|
||||
ZARINPAL_FRONTEND_FAILURE_URL=https://ifnex.ir/wallet
|
||||
|
||||
# Kavenegar — کلید نهایی از پنل ادمین تنظیم میشود، نه از این فایل
|
||||
KAVENEGAR_API_KEY=
|
||||
KAVENEGAR_SENDER=
|
||||
```
|
||||
|
||||
> 🔐 **نکتهٔ امنیتی:** مقدار `change-this-secret-key` عمداً انتخاب شده است، چون `BridgeAuthController` دقیقاً همین مقدار را رد میکند و خطای ۵۰۰ میدهد — یعنی اگر جایگزینی فراموش شود، سیستم بیسروصدا کار نمیکند. هر مقدار دیگری (حتی مواردی که شبیه placeholder باشند) بهعنوان یک کلید معتبر پذیرفته میشود.
|
||||
>
|
||||
> ⚠️ اگر کلیدی قبلاً در این مخزن یا در `.env` اشتراکی ثبت شده، آن را **لورفته فرض کنید و حتماً چرخش دهید**.
|
||||
|
||||
> 📱 **نکتهٔ `ZARINPAL_FRONTEND_SUCCESS_URL`:** این کلید در کد خوانده نمیشود. ریدایرکت بعد از پرداخت موفق از طریق `frontend_callback` که وردپرس در هر درخواست پرداخت ارسال میکند تعیین میشود؛ فقط `ZARINPAL_FRONTEND_FAILURE_URL` بهعنوان fallback استفاده میشود.
|
||||
|
||||
> 📱 **نکتهٔ SMS:** سرویس پیامک از تنظیمات پنل ادمین (Filament → تنظیمات سیستم → SystemSetting) خوانده میشود: `kavenegar_api_key` و سوییچهای `kavenegar_send_*` برای هر نوع اعلان. مقداردهی این متغیرها در `.env` کافی نیست.
|
||||
|
||||
### ۱.۴ دیتابیس
|
||||
|
||||
```bash
|
||||
# ایجاد دیتابیس (اگر وجود نداره)
|
||||
mysql -u root -p -e "CREATE DATABASE ifnex_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
|
||||
# اجرای migrations
|
||||
php artisan migrate --force
|
||||
|
||||
# اجرای seeders (فقط بار اول)
|
||||
php artisan db:seed --force
|
||||
```
|
||||
|
||||
### ۱.۵ پیکربندی نهایی
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan filament:clear-cached-components
|
||||
```
|
||||
|
||||
### ۱.۶ آپلود تعهدنامهها و دیسک امن
|
||||
|
||||
فایلهای آپلودی مشتری (تعهدنامههای امضاشده) روی دیسک `secure` در `storage/app/secure` ذخیره میشوند و URL عمومی ندارند — فقط از طریق روتهای محافظتشده (`/api/v1/customer/...` و `/admin/shipments/...`) سرو میشوند:
|
||||
|
||||
```bash
|
||||
# مسیر دیسک امن باید قابل نوشتن باشد
|
||||
mkdir -p storage/app/secure && chmod -R 775 storage
|
||||
```
|
||||
|
||||
محدودیت آپلود PHP سرور باید با اعتبارسنجی اپلیکیشن (۵ مگابایت) همخوان باشد — در php.ini مربوط به PHP-FPM:
|
||||
|
||||
```ini
|
||||
upload_max_filesize = 8M
|
||||
post_max_size = 8M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۲. استقرار وردپرس (پلاگین و قالب)
|
||||
|
||||
### ۲.۱ آپلود فایلها
|
||||
|
||||
```bash
|
||||
# قالب IFNEX
|
||||
cd /home/USER/web/ifnex.vernahost.ir/public_html/wp-content/themes/
|
||||
# فایلهای قالب را اینجا آپلود/بروزرسانی کن
|
||||
|
||||
# پلاگین IFNEX Bridge
|
||||
cd /home/USER/web/ifnex.vernahost.ir/public_html/wp-content/plugins/
|
||||
# فایلهای پلاگین را اینجا آپلود/بروزرسانی کن
|
||||
```
|
||||
|
||||
### ۲.۲ تنظیم پلاگین
|
||||
|
||||
در پیشخوان وردپرس → تنظیمات → IFNEX Bridge:
|
||||
|
||||
| تنظیم | مقدار |
|
||||
|--------|-------|
|
||||
| API URL | `https://api.ifnex.vernahost.ir/api/v1` |
|
||||
| API Key (عمومی) | برای APIهای عمومی مثل Tracking (همان `IFNEX_API_KEY` در `.env` لاراول) |
|
||||
| Bridge API Key | کلید مشترک بین وردپرس و لاراول (همان `IFNEX_BRIDGE_API_KEY` در `.env` لاراول) |
|
||||
|
||||
> ⚠️ **مهم:** Bridge API Key باید در لاراول (`.env`) و وردپرس (پنل تنظیمات پلاگین) دقیقاً یکسان باشد. در غیر این صورت، کاربران وردپرس نمیتوانند توکن Sanctum دریافت کنند.
|
||||
|
||||
### ۲.۳ صفحات مورد نیاز در وردپرس
|
||||
|
||||
این صفحات باید با شورتکدهای مشخص ساخته شوند:
|
||||
|
||||
| آدرس | شورتکد | توضیح |
|
||||
|------|---------|--------|
|
||||
| `/my-account/` | `[ifnex_customer_dashboard]` | پنل جامع مشتری |
|
||||
| `/my-orders/` | `[ifnex_orders_list]` | لیست سفارشات |
|
||||
| `/new-order/` | `[ifnex_order_form]` | فرم ثبت سفارش |
|
||||
| `/order-detail/` | `[ifnex_order_detail]` | جزئیات سفارش |
|
||||
| `/order-payment/` | `[ifnex_order_payment]` | صفحه پرداخت |
|
||||
| `/wallet/` | `[ifnex_wallet_charge]` | شارژ کیف پول |
|
||||
| `/tracking/` | `[ifnex_tracking_form]` | رهگیری مرسوله |
|
||||
|
||||
---
|
||||
|
||||
## ۳. بروزرسانی (بعد از تغییرات جدید)
|
||||
|
||||
### ۳.۱ لاراول
|
||||
|
||||
```bash
|
||||
cd /home/USER/web/api.ifnex.vernahost.ir/public_html
|
||||
|
||||
# خاموش کردن موقت سایت
|
||||
php artisan down
|
||||
|
||||
# بکاپ
|
||||
cp .env .env.backup
|
||||
|
||||
# دریافت تغییرات
|
||||
git fetch --all
|
||||
git pull origin main
|
||||
|
||||
# وابستگیها (اگه composer.json تغییر کرده)
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
# مایگریشنهای جدید
|
||||
php artisan migrate --force
|
||||
|
||||
# پاکسازی کش
|
||||
php artisan config:clear
|
||||
php artisan cache:clear
|
||||
php artisan route:clear
|
||||
php artisan view:clear
|
||||
php artisan filament:clear-cached-components
|
||||
|
||||
# کش مجدد
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
|
||||
# روشن کردن سایت
|
||||
php artisan up
|
||||
```
|
||||
|
||||
### ۳.۲ وردپرس
|
||||
|
||||
```bash
|
||||
cd /home/USER/web/ifnex.vernahost.ir/public_html/wp-content/plugins/ifnex-bridge
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
> اگر پلاگین از طریق گیت کلون نشده، فایلها را دستی آپلود کن.
|
||||
|
||||
---
|
||||
|
||||
## ۴. تنظیمات Cron
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
```cron
|
||||
# بهروزرسانی نرخ ارز
|
||||
0 0 * * * cd /home/USER/web/api.ifnex.vernahost.ir/public_html && php artisan ifnex:update-rates --source=freecurrencyapi >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۵. عیبیابی
|
||||
|
||||
### بررسی لاگها
|
||||
|
||||
```bash
|
||||
# لاگ لاراول
|
||||
tail -f /home/USER/web/api.ifnex.vernahost.ir/public_html/storage/logs/laravel.log
|
||||
|
||||
# لاگ HestiaCP
|
||||
tail -f /var/log/hestia.log
|
||||
```
|
||||
|
||||
### کلیر کش
|
||||
### 4. Clear caches
|
||||
|
||||
```bash
|
||||
php artisan optimize:clear
|
||||
php artisan config:clear
|
||||
php artisan route:clear
|
||||
php artisan view:clear
|
||||
php artisan filament:assets
|
||||
```
|
||||
|
||||
### بررسی وضعیت
|
||||
### 5. Visit the admin panel
|
||||
|
||||
Navigate to: `https://system.ifnex.ir/admin/rate-import`
|
||||
|
||||
You should see the upload form. Upload the `IFNEX_Rate_Template.xlsx` (with your filled-in rates) and click **Import Rates**.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Filament v3 compatibility notes
|
||||
|
||||
This page targets **Filament 3.x**. Key points:
|
||||
|
||||
1. **No `CanPoll` trait** — that trait was removed in Filament 3. We use `InteractsWithForms` instead.
|
||||
2. **Form state path** — form values are stored in `$data` array, set via `->statePath('data')` on the form.
|
||||
3. **`mount()` method** — required to initialize the form on page load via `$this->form->fill()`.
|
||||
4. **Form rendering** — the view calls `{{ $this->form }}` directly inside the `<form wire:submit="import">` tag.
|
||||
5. **FileUpload disk** — uses the `local` disk (storage/app/). The uploaded file path returned is relative to that disk, so we prepend `storage_path('app/')`.
|
||||
|
||||
If you see "Trait not found" errors, double-check you're on Filament 3.x:
|
||||
```bash
|
||||
composer show filament/filament | grep versions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Template structure (matches customer's Excel exactly)
|
||||
|
||||
### Sheet 1: `Import Rate` — ROW → IRAN rates
|
||||
|
||||
```
|
||||
Row 1: IFNEX IMPORT RATE TABLE — ROW → IRAN
|
||||
Row 2: (subtitle)
|
||||
Row 4: [merged E4:K4] "DOCUMENT"
|
||||
Row 5: Weight (kg) | Zone 1 | Zone 2 | ... | Zone 10
|
||||
Row 6: 0.5 | <rate> | <rate> | ... | <rate>
|
||||
Row 7: 1 | <rate> | ...
|
||||
Row 8: 1.5 | ...
|
||||
Row 9: 2 | ...
|
||||
|
||||
Row 11: [merged] "NON - DOCUMENT"
|
||||
Row 12: Weight (kg) | Zone 1 | ... | Zone 10
|
||||
Row 13: 0.5 | <rate>
|
||||
... (60 rows: 0.5 → 30.0 in 0.5 steps)
|
||||
Row 73: 30.0 | <rate>
|
||||
Row 75: ★ Anything above 30 kg requires Spot/Special rates.
|
||||
```
|
||||
|
||||
### Sheet 2: `Export Rate` — IRAN → ROW rates
|
||||
Same structure as Import Rate.
|
||||
|
||||
### Sheet 3: `Zone` — country → zone mapping
|
||||
|
||||
```
|
||||
Cols A-C: No. | Destination Country | PARCEL Zone
|
||||
Cols E-G: No. | Destination Country | DOCUMENT Zone
|
||||
|
||||
CRUCIAL: same country can have DIFFERENT zones!
|
||||
Afghanistan → Parcel Zone 7, Document Zone 5
|
||||
Belgium → Parcel Zone 1, Document Zone 3
|
||||
China → Parcel Zone 6, Document Zone 4
|
||||
```
|
||||
|
||||
### Sheet 4: `README` — instructions (not parsed)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 How the importer detects sections
|
||||
|
||||
The `RateImportService` walks every row and looks for:
|
||||
|
||||
1. **Section markers** — text cells in column E containing `DOCUMENT` or `NON - DOCUMENT`
|
||||
2. **Column headers** — `Weight (kg)` in column A + at least one `Zone N` header
|
||||
3. **Data rows** — numeric weight in column A + numeric rates in zone columns
|
||||
|
||||
Section markers are normalized: `DOCUMENT`, `Doc`, `DOC NORMAL` all map to `'document'`.
|
||||
Similarly `NON - DOCUMENT`, `NON-DOCUMENT`, `NONDOC`, `PARCEL` all map to `'parcel'`.
|
||||
|
||||
Empty cells in the rate matrix are skipped (not stored) — the calculator will use the
|
||||
next-higher weight tier.
|
||||
|
||||
---
|
||||
|
||||
## 💱 Currency flow
|
||||
|
||||
1. Rates are stored in **AED** (matches customer's Excel format).
|
||||
2. `PriceCalculatorService::calculate()` looks up the AED rate.
|
||||
3. `ExchangeRateService::convertToIrr($aedAmount, 'AED')` converts to IRR at the current rate.
|
||||
4. Result is rounded to the nearest 1,000 IRR.
|
||||
|
||||
If you ever store IRR rates directly (e.g. by uploading with currency = IRR), the calculator
|
||||
will return them without conversion.
|
||||
|
||||
---
|
||||
|
||||
## ✅ What changed from the old model
|
||||
|
||||
| Aspect | Old (2-zone) | New (4-quadrant) |
|
||||
|---|---|---|
|
||||
| Quadrants | 2 (Doc / Parcel) | 4 (Import×Doc, Import×Parcel, Export×Doc, Export×Parcel) |
|
||||
| Zone mapping | Single zone per country | Separate Parcel Zone + Document Zone per country |
|
||||
| Currency | Mixed | AED in DB, IRR at runtime |
|
||||
| History | No `effective_at` | `effective_at` timestamp + lookup picks latest |
|
||||
| Weight rounding | Buggy (`<=` matched wrong) | Correct (`>=` rounds UP to next tier) |
|
||||
| Excel format | Custom narrow format | Customer's exact operational Excel format |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verifying the import worked
|
||||
|
||||
After uploading, you should see a notification like:
|
||||
|
||||
```
|
||||
✓ Imported: 480 | Updated: 0 | Skipped: 12 | Zone rows: 32
|
||||
```
|
||||
|
||||
Math check:
|
||||
- Import Rate sheet: 4 doc weights × 10 zones = 40 cells
|
||||
- Import Rate sheet: 60 parcel weights × 10 zones = 600 cells
|
||||
- Export Rate sheet: same — 40 + 600 = 640 cells
|
||||
- Total expected per direction: 640 → 1,280 across both sheets
|
||||
- Minus skipped empty cells
|
||||
|
||||
You can verify in `tinker`:
|
||||
|
||||
```bash
|
||||
php artisan about
|
||||
php artisan route:list --path=api/v1
|
||||
php artisan tinker
|
||||
>>> App\Models\RateEntry::count();
|
||||
=> 1280 // expected count
|
||||
|
||||
>>> App\Models\RateEntry::where('direction', 'export')->where('service_type', 'document')->count();
|
||||
=> 40
|
||||
|
||||
>>> App\Models\CountryZone::count();
|
||||
=> 32 // countries in the Zone sheet
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۶. نکات امنیتی
|
||||
## 🐛 Common issues
|
||||
|
||||
- `.env` هرگز در گیت کامیت نشود (در `.gitignore` باشد)
|
||||
- `APP_DEBUG=false` در Production
|
||||
- `CORS_ALLOWED_ORIGINS` فقط دامنه وردپرس
|
||||
- `IFNEX_API_KEY` یک کلید قوی و تصادفی باشد
|
||||
- `IFNEX_BRIDGE_API_KEY` یک کلید قوی و تصادفی باشد (متفاوت از API Key)
|
||||
- SSL/HTTPS فعال باشد
|
||||
- دسترسی پوشه `storage` و `bootstrap/cache` قابل نوشتن باشد (۷۷۵)
|
||||
- درگاه Zarinpal در Production باید `SANDBOX=false` و `MERCHANT_ID` واقعی باشد
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `No rate found` error when calculating | Country has no zone in `country_zones` | Upload the Zone sheet, OR add the country manually in admin |
|
||||
| `Non-numeric rate at weight=0.5 zone=3` warning | Cell contains text (e.g. "TBD") | Replace with empty cell or a number |
|
||||
| Imported count is 0 | Sheet names don't match exactly | Rename sheets to `Import Rate` and `Export Rate` |
|
||||
| Section marker not detected | Marker text in wrong column | Move `DOCUMENT` / `NON - DOCUMENT` text to column E |
|
||||
| Calculator returns wrong weight tier | Old `PriceCalculatorService` cached | `php artisan optimize:clear` + restart PHP-FPM |
|
||||
|
||||
---
|
||||
|
||||
© 2026 VernaSoft Group. Internal use only.
|
||||
## 🔄 Re-importing rates
|
||||
|
||||
You can re-upload the same file anytime. Existing rows with the same
|
||||
`(direction, service_type, weight, zone, currency, effective_at)` will be UPDATED
|
||||
rather than duplicated. Old rates from previous dates are preserved for history.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Roadmap (not yet implemented)
|
||||
|
||||
- [ ] Filament admin page to view/edit `rate_entries` directly (matrix view)
|
||||
- [ ] Country management page for `country_zones`
|
||||
- [ ] Bulk export current rates back to Excel
|
||||
- [ ] Pre-import preview ("This will import X rows, update Y. Continue?")
|
||||
- [ ] Scheduled rate imports from S3/URL
|
||||
|
||||
Loading…
Reference in New Issue
Block a user