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;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\RateEntry;
|
||||||
|
use App\Models\CountryZone;
|
||||||
|
use App\Enums\ShipmentDirection;
|
||||||
use App\Enums\ShipmentType;
|
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
|
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
|
public function calculate(
|
||||||
{
|
|
||||||
// 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(
|
|
||||||
string $direction,
|
string $direction,
|
||||||
ShipmentType $type,
|
string $serviceType,
|
||||||
float $weight,
|
float $weightKg,
|
||||||
int $zone
|
string $countryName,
|
||||||
): float {
|
): array {
|
||||||
$zoneColumn = 'zone_' . $zone;
|
// 1. Look up zone for this country × service type
|
||||||
|
$zone = CountryZone::lookupZone($countryName, $serviceType);
|
||||||
// نرخی را پیدا کن که وزنش بزرگتر مساوی وزن واردشده باشد
|
if ($zone === null) {
|
||||||
// مثلاً اگه وزن 0.3 است، نرخ 0.5 را برمیگرداند
|
throw new \RuntimeException(
|
||||||
$rate = \App\Models\ShippingRate::query()
|
"No zone mapping for country '{$countryName}' service type '{$serviceType}'"
|
||||||
->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(
|
|
||||||
'برای این مسیر و وزن، نرخی تعریف نشده است. لطفاً با پشتیبانی تماس بگیرید.'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
| محیط | دامنه | نقش |
|
| File | Purpose |
|
||||||
|-------|-------|------|
|
|---|---|
|
||||||
| Production | api.ifnex.vernahost.ir (دامنه اختصاصی آینده: system.ifnex.ir) | API لاراول |
|
| `IFNEX_Rate_Template.xlsx` | The new blank template — give this to the customer |
|
||||||
| Production | ifnex.vernahost.ir (دامنه اختصاصی آینده: ifnex.ir) | وبسایت وردپرس |
|
| `migrations/2026_10_29_000001_extend_rate_entries_for_4_quadrants.php` | DB schema: `rate_entries` (4-quadrant) + `country_zones` |
|
||||||
| Local | localhost:8000 | توسعه |
|
| `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 دسترسی به سرور
|
### 1. Copy files into your Laravel project
|
||||||
- HestiaCP (مدیریت سرور)
|
|
||||||
- Git روی سرور
|
|
||||||
- Composer روی سرور (اختیاری — بهتره locallly نصب کنی)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ۱. استقرار لاراول
|
|
||||||
|
|
||||||
### ۱.۱ کلون مخزن روی سرور
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/USER/web/api.ifnex.vernahost.ir/public_html
|
# From your project root (e.g. /home/ifnex/web/system.ifnex.ir)
|
||||||
git clone https://www.git.vernahost.ir/gitmodir110/ifnex.git .
|
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
|
```bash
|
||||||
cd /home/USER/web/api.ifnex.vernahost.ir/public_html
|
composer require phpoffice/phpspreadsheet
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### ۱.۲ نصب وابستگیها
|
### 3. Run the migration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/USER/web/api.ifnex.vernahost.ir/public_html
|
php artisan migrate
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### ۱.۳ تنظیم .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
|
### 4. Clear caches
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### کلیر کش
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan optimize:clear
|
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
|
```bash
|
||||||
php artisan about
|
php artisan tinker
|
||||||
php artisan route:list --path=api/v1
|
>>> 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` باشد)
|
| Symptom | Cause | Fix |
|
||||||
- `APP_DEBUG=false` در Production
|
|---|---|---|
|
||||||
- `CORS_ALLOWED_ORIGINS` فقط دامنه وردپرس
|
| `No rate found` error when calculating | Country has no zone in `country_zones` | Upload the Zone sheet, OR add the country manually in admin |
|
||||||
- `IFNEX_API_KEY` یک کلید قوی و تصادفی باشد
|
| `Non-numeric rate at weight=0.5 zone=3` warning | Cell contains text (e.g. "TBD") | Replace with empty cell or a number |
|
||||||
- `IFNEX_BRIDGE_API_KEY` یک کلید قوی و تصادفی باشد (متفاوت از API Key)
|
| Imported count is 0 | Sheet names don't match exactly | Rename sheets to `Import Rate` and `Export Rate` |
|
||||||
- SSL/HTTPS فعال باشد
|
| Section marker not detected | Marker text in wrong column | Move `DOCUMENT` / `NON - DOCUMENT` text to column E |
|
||||||
- دسترسی پوشه `storage` و `bootstrap/cache` قابل نوشتن باشد (۷۷۵)
|
| Calculator returns wrong weight tier | Old `PriceCalculatorService` cached | `php artisan optimize:clear` + restart PHP-FPM |
|
||||||
- درگاه Zarinpal در Production باید `SANDBOX=false` و `MERCHANT_ID` واقعی باشد
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
© 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