ifnex/04_Laravel/app/Console/Commands/IfnexImportFxRates.php
Kazem Alghasi f1d5667fe8 feat(deploy): preflight + smoke-test + UAT checklist (phases 3.9.1–3.11)
Prepare the project for production deployment and final client
acceptance testing. Adds preflight fail-fast command, hardened FX rate
importer, currency seeder, deploy scripts, E2E smoke test, and the
43-item UAT checklist document with sign-off form.

Key changes:

- Phase 3.9.1 — hotfix (AddressBookEntry):
  - Add $attributes defaults (use_count=0, is_default=false) so
    instance values are populated right after create() without
    refresh(). Resolves the red test
    `it_increments_use_count_and_sets_last_used_at`.

- Phase 3.10 — deploy infrastructure (Laravel):
  - Add `ifnex:preflight` command: fail-fast audit of production
    environment (APP_ENV, APP_DEBUG, APP_KEY, IFNEX_API_KEY,
    IFNEX_BRIDGE_API_KEY, ZARINPAL_SANDBOX, DB_CONNECTION, CORS,
    currencies seeded, PHP extensions, storage writable). Exits 1
    if any check fails; supports --skip-db for pre-migrate runs.
  - Add `ifnex:import-fx-rates` command: replacement for
    `ifnex:update-rates`. Fetches USD/AED from Exir.io + EUR/CNY
    from FreeCurrencyAPI. Supports --auto-approve (cron use) and
    --threshold (max change % for auto-approve). Logs critical on
    HTTP failure. URL configurable via EXIR_API_URL env.
  - Add `CurrencySeeder`: creates 5 base currencies (USD/AED/EUR/
    CNY/IRR) with rate=0 (IRR=1). Idempotent — does not overwrite
    real rates already set by admin or cron.
  - Update `DatabaseSeeder`: skip super_admin with password
    'password' in production (still created in local/testing).
  - Fix `bootstrap/app.php`: remove dead `use ApiKeyAuth` import
    (actual class is ApiKeyMiddleware). Latent fatal if api_key
    alias was used.
  - Add `config/ifnex.php` exir block + preflight config.
  - Harden `.env.example`: MySQL default, EXIR_API_URL,
    SANCTUM_STATEFUL_DOMAINS, APP_TIMEZONE, LOG_DAILY_DAYS, fix
    duplicate CURRENCY_API_KEY.

- Phase 3.10 — deploy scripts (Linux):
  - Add `scripts/deploy.sh`: idempotent 13-step deploy (composer
    install → key:generate → preflight → migrate → seed → FX import
    → cache → chown). Fail-fast with set -e.
  - Add `scripts/cron-exir-rates.sh`: daily cron wrapper for
    `ifnex:import-fx-rates --source=all --auto-approve`.

- Phase 3.10.1 — preflight hotfix:
  - Make expected DB connection configurable
    (ifnex.preflight.expected_db_connection) instead of hardcoded
    'mysql'. Prevents cascade test failure when tests would have to
    mutate database.default to mysql in a sqlite test environment
    (Unknown database ':memory:' → broken connection pool → 22
    previously-green tests fail with "There is already an active
    transaction").
  - Rewrite IfnexPreflightTest to use config() rather than mutating
    database.default.

- Phase 3.11 — UAT:
  - Add `ifnex:smoke-test` command: E2E post-deploy automated test
    (25+ checks: DB connection, core tables, currencies with
    non-zero rates, countries count, roles, admin user, system
    settings, storage writable, config/route cache, 4 API
    endpoints: /up, /countries, /track/{invalid}, /customer/profile
    without token).
  - Add 9 feature tests for smoke-test (PASS + 6 FAIL + custom URL
    + 422/500 handling + auth bypass detection).
  - Add `UAT-3.11.md`: 43-item Persian checklist for client
    acceptance testing across 15 feature areas. Includes sign-off
    form for both parties (client + VernaSoft).
  - Update `CLIENT_DELIVERY.md` to record phases 3.10 and 3.11.

Tests:
- Total: 51 → 82 tests, 150 → 220+ assertions, all green.
- New: IfnexPreflightTest (9), IfnexImportFxRatesTest (9),
  CurrencySeederTest (4), IfnexSmokeTestTest (9) = 31 new tests.

Documentation:
- DEPLOYMENT_HANDOFF.md §2.4: add CurrencySeeder + import-fx-rates.
- DEPLOYMENT_HANDOFF.md §2.8: update cron to use cron-exir-rates.sh.
- UAT-3.11.md: new 43-item client acceptance checklist + sign-off.
- CLIENT_DELIVERY.md: record phases 3.10 and 3.11 completion.
2026-10-31 22:18:15 +03:30

336 lines
13 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Models\Currency;
use App\Models\ExchangeRateHistory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* IfnexImportFxRates — فاز ۳.۱۰
*
* جایگزین و نسخهٔ ارتقایافتهٔ `ifnex:update-rates`. تفاوت‌ها:
* - پشتیبانی از EUR و CNY (علاوه بر USD و AED) از طریق freecurrencyapi.com
* - پرچم `--auto-approve` برای استفادهٔ cron: نرخ‌های sub-threshold خودکار
* تأیید و در جدول currencies نوشته می‌شوند (نه فقط در صف pending).
* - URL اکسیر از env خوانده می‌شود (نه hardcode).
* - در صورت شکست HTTP، Log::critical صدا زده می‌شود.
* - اگه جدول currencies خالی باشد، خطای واضح می‌دهد (نه سکوت).
*
* نمونهٔ استفاده:
* php artisan ifnex:import-fx-rates
* php artisan ifnex:import-fx-rates --source=exir
* php artisan ifnex:import-fx-rates --source=exir --auto-approve
* php artisan ifnex:import-fx-rates --source=all --auto-approve # برای cron
*
* برای cron روزانه (DEPLOYMENT_HANDOFF.md §۲.۸):
* 0 2 * * * cd /var/www/system.ifnex.ir && php artisan ifnex:import-fx-rates --source=all --auto-approve
*/
class IfnexImportFxRates extends Command
{
protected $signature = 'ifnex:import-fx-rates
{--source=exir : exir|freecurrencyapi|all}
{--auto-approve : نرخ‌های زیر آستانه را خودکار تأیید و در currencies بنویس}
{--threshold=5 : درصد حداکثر تغییر مجاز برای auto-approve (پیش‌فرض ۵٪)}';
protected $description = 'دریافت نرخ ارز از Exir.io (USD/AED) و/یا FreeCurrencyAPI (EUR/CNY) و ذخیره در دیتابیس';
/** آستانهٔ percent تغییر برای ثبت رکورد جدید (٪) */
private const CHANGE_THRESHOLD_PCT = 0.5;
/** کدهایی که از Exir.io دریافت شدند — تا FreeCurrencyAPI آنها را overwrite نکند */
private array $ratesFromExir = [];
public function handle(): int
{
$source = (string) $this->option('source');
$autoApprove = (bool) $this->option('auto-approve');
$threshold = (float) $this->option('threshold');
if (! in_array($source, ['exir', 'freecurrencyapi', 'all'], true)) {
$this->error("مقدار --source نامعتبر است. مجاز: exir, freecurrencyapi, all");
return Command::INVALID;
}
$this->warnIfCurrenciesMissing();
$rates = [];
if ($source === 'exir' || $source === 'all') {
$exirRates = $this->fetchFromExir();
$rates = array_merge($rates, $exirRates);
// ثبت کدهایی که از Exir آمدند تا FreeCurrencyAPI آنها را overwrite نکند
foreach (array_keys($exirRates) as $code) {
$this->ratesFromExir[$code] = true;
}
}
if ($source === 'freecurrencyapi' || $source === 'all') {
$rates = array_merge($rates, $this->fetchFromFreeCurrencyApi());
}
if (empty($rates)) {
$this->error('❌ هیچ نرخی دریافت نشد. بررسی لاگ برای جزئیات.');
Log::critical('ifnex:import-fx-rates — هیچ نرخی دریافت نشد.', [
'source' => $source,
]);
return Command::FAILURE;
}
$created = 0;
$approved = 0;
foreach ($rates as $code => $rate) {
[$c, $a] = $this->upsertRate($code, $rate, $autoApprove, $threshold);
$created += $c;
$approved += $a;
}
$this->newLine();
$this->info("✅ دریافت نرخ انجام شد. کل ارزها: " . count($rates));
$this->line(" رکورد جدید در exchange_rate_history: {$created}");
$this->line(" نرخ‌های تأییدشده در currencies: {$approved}");
if (! $autoApprove) {
$this->newLine();
$this->warn('ℹ️ نرخ‌ها به‌صورت pending ثبت شدند. برای اعمال فوری:');
$this->line(' php artisan ifnex:import-fx-rates --source=all --auto-approve');
$this->line(' یا از پنل ادمین ← تاریخچه نرخ ارز ← تأیید.');
}
return Command::SUCCESS;
}
// ─── Exir.io ────────────────────────────────────────────
private function fetchFromExir(): array
{
$url = (string) (config('ifnex.exir.api_url') ?? 'https://api.exir.io/v2/minicharts');
$timeout = (int) (config('ifnex.exir.timeout') ?? 15);
$this->line("→ در حال دریافت از Exir.io: {$url}");
try {
$response = Http::timeout($timeout)->get($url);
} catch (Throwable $e) {
$this->error("خطا در اتصال به Exir.io: {$e->getMessage()}");
Log::critical('ifnex:import-fx-rates — Exir.io unreachable', [
'url' => $url,
'error' => $e->getMessage(),
]);
return [];
}
if ($response->failed()) {
$this->error("Exir.io پاسخ ناموفق داد: HTTP {$response->status()}");
Log::critical('ifnex:import-fx-rates — Exir.io HTTP failure', [
'url' => $url,
'status' => $response->status(),
]);
return [];
}
$data = $response->json();
$rates = [];
// نگاشت جفت‌ارزهای اکسیر به کدهای استاندارد ما
$symbolMap = [
'usdt-rls' => 'USD', // 1 دلار = X ریال (قیمت مستقیم ریالی)
'usdt-aed' => 'AED', // 1 درهم = X تتر → باید در نرخ دلار ضرب شود
];
foreach ($data as $symbol => $info) {
if (! isset($symbolMap[$symbol])) {
continue;
}
$code = $symbolMap[$symbol];
$price = (float) ($info['price'] ?? 0);
if ($price <= 0) {
continue;
}
if ($code === 'USD') {
$rates['USD'] = round($price, 2);
}
// برای درهم: قیمت بر اساس تتر است؛ باید در نرخ دلار ضرب شود تا ریالی شود
if ($code === 'AED' && isset($rates['USD'])) {
$rates['AED'] = round($price * $rates['USD'], 2);
}
}
$this->line(' Exir.io دریافت شد: ' . json_encode($rates, JSON_UNESCAPED_UNICODE));
return $rates;
}
// ─── FreeCurrencyAPI (EUR/CNY) ──────────────────────────
private function fetchFromFreeCurrencyApi(): array
{
$apiKey = (string) config('ifnex.currency.api_key');
if ($apiKey === '' || $apiKey === 'your_api_key_here') {
$this->warn(' CURRENCY_API_KEY تنظیم نشده — پرش از منبع freecurrencyapi.');
$this->line(' برای دریافت EUR/CNY لازم است: https://app.freecurrencyapi.com/');
return [];
}
$url = (string) config('ifnex.currency.api_url');
$base = (string) config('ifnex.currency.base_currency', 'USD');
$fullUrl = "{$url}?apikey={$apiKey}&base_currency={$base}&currencies=IRR,EUR,CNY";
$this->line("→ در حال دریافت از FreeCurrencyAPI: {$url}");
try {
$response = Http::timeout(15)->get($fullUrl);
} catch (Throwable $e) {
$this->error("خطا در اتصال به FreeCurrencyAPI: {$e->getMessage()}");
Log::critical('ifnex:import-fx-rates — FreeCurrencyAPI unreachable', [
'error' => $e->getMessage(),
]);
return [];
}
if ($response->failed()) {
$this->error("FreeCurrencyAPI پاسخ ناموفق داد: HTTP {$response->status()}");
Log::critical('ifnex:import-fx-rates — FreeCurrencyAPI HTTP failure', [
'status' => $response->status(),
]);
return [];
}
$data = $response->json('data', []);
$rates = [];
// تبدیل نسبت دلار به ریال: free_currency_api نرخ 1USD = X IRR می‌دهد
if (isset($data['IRR']) && $data['IRR'] > 0) {
$usdToIrr = (float) $data['IRR'];
// USD را overwrite نکن اگر از Exir آمده — Exir در ایران دقیق‌تر است.
// فقط اگه Exir نداشتیم، از اینجا پر کن.
if (! isset($this->ratesFromExir['USD'])) {
$rates['USD'] = round($usdToIrr, 2);
}
// EUR = usd_to_eur × usd_to_irr
if (isset($data['EUR']) && $data['EUR'] > 0) {
$rates['EUR'] = round(((float) $data['EUR']) * $usdToIrr, 2);
}
// CNY = usd_to_cny × usd_to_irr
if (isset($data['CNY']) && $data['CNY'] > 0) {
$rates['CNY'] = round(((float) $data['CNY']) * $usdToIrr, 2);
}
}
$this->line(' FreeCurrencyAPI دریافت شد: ' . json_encode($rates, JSON_UNESCAPED_UNICODE));
return $rates;
}
// ─── Upsert نرخ ─────────────────────────────────────────
/**
* @return array{0:int,1:int} [records_created_in_history, records_approved_in_currencies]
*/
private function upsertRate(string $code, float $newRate, bool $autoApprove, float $threshold): array
{
$code = strtoupper($code);
$currency = Currency::where('code', $code)->first();
if (! $currency) {
$this->warn(" ارز {$code} در جدول currencies وجود ندارد — پرش.");
Log::warning("ifnex:import-fx-rates — currency {$code} not found in table.");
return [0, 0];
}
$oldRate = (float) $currency->rate_to_rial;
// اگر نرخ صفر است (اولین بار)، بدون بررسی threshold بنویس
if ($oldRate <= 0) {
return $this->applyApproved($currency, $oldRate, $newRate, $code, source: $this->currentSource($code));
}
$changePct = abs($newRate - $oldRate) / $oldRate * 100;
// اگر تغییر قابل‌توجه نیست (>0.5٪)، نادیده بگیر
if ($changePct < self::CHANGE_THRESHOLD_PCT) {
$this->line(" {$code}: تغییر {$changePct}٪ < آستانه — پرش.");
return [0, 0];
}
// اگر تغییر بیش از threshold auto-approve است، حتی با auto-approve فقط pending بساز
if ($autoApprove && $changePct <= $threshold) {
return $this->applyApproved($currency, $oldRate, $newRate, $code, source: $this->currentSource($code));
}
// در غیر این صورت، فقط رکورد pending بساز
ExchangeRateHistory::create([
'currency_from' => $code,
'currency_to' => 'IRR',
'old_rate' => $oldRate,
'new_rate' => $newRate,
'change_percent' => round((($newRate - $oldRate) / $oldRate) * 100, 4),
'change_source' => 'api',
'api_source' => $this->currentSource($code),
'status' => 'pending',
]);
$this->line(" {$code}: رکورد pending ساخته شد (تغییر {$changePct}٪).");
return [1, 0];
}
private function applyApproved(Currency $currency, float $oldRate, float $newRate, string $code, string $source): array
{
$currency->update(['rate_to_rial' => $newRate]);
ExchangeRateHistory::create([
'currency_from' => $code,
'currency_to' => 'IRR',
'old_rate' => $oldRate,
'new_rate' => $newRate,
'change_percent' => $oldRate > 0 ? round((($newRate - $oldRate) / $oldRate) * 100, 4) : null,
'change_source' => 'api',
'api_source' => $source,
'approved_at' => now(),
'status' => 'approved',
]);
$this->line(" {$code}: نرخ در currencies نوشته شد (auto-approved، منبع {$source}).");
return [1, 1];
}
private function currentSource(string $code): string
{
return in_array($code, ['USD', 'AED'], true) ? 'exir.io' : 'freecurrencyapi.com';
}
// ─── هشدار در صورت خالی بودن جدول currencies ────────────
private function warnIfCurrenciesMissing(): void
{
$count = Currency::count();
if ($count === 0) {
$this->warn('⚠️ جدول currencies خالی است! ابتدا اجرا کنید:');
$this->line(' php artisan db:seed --class=CurrencySeeder --force');
$this->newLine();
}
}
}