Initial commit
This commit is contained in:
205
api/includes/CnbRates.php
Normal file
205
api/includes/CnbRates.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Devizove kurzy CNB s file cache per datum.
|
||||
*
|
||||
* Pouziti:
|
||||
* $cnb = CnbRates::getInstance();
|
||||
* $czk = $cnb->toCzk(1000.0, 'EUR', '2026-03-01');
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class CnbRates
|
||||
{
|
||||
private const API_URL = 'https://api.cnb.cz/cnbapi/exrates/daily';
|
||||
private const CACHE_FILE = 'cnb_rates_cache.json';
|
||||
|
||||
// Kurzy starsi nez dnesek se nemeni, cachujem navzdy.
|
||||
// Dnesni kurz cachujeme na 6 hodin (muze se behem dne aktualizovat).
|
||||
private const TODAY_CACHE_TTL = 21600;
|
||||
|
||||
/**
|
||||
* In-memory cache: date => currency => {rate, amount}
|
||||
* @var array<string, array<string, array{rate: float, amount: int}>>
|
||||
*/
|
||||
private array $ratesByDate = [];
|
||||
|
||||
private static ?CnbRates $instance = null;
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$this->loadCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevede castku na CZK dle kurzu platneho k danemu datu.
|
||||
*/
|
||||
public function toCzk(float $amount, string $currency, string $date = ''): float
|
||||
{
|
||||
if ($currency === 'CZK') {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$rates = $this->getRatesForDate($date ?: date('Y-m-d'));
|
||||
if (!isset($rates[$currency])) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$info = $rates[$currency];
|
||||
return $amount * ($info['rate'] / $info['amount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secte pole [{amount, currency, date?}] do jedne CZK castky.
|
||||
* Kazda polozka muze mit vlastni datum pro kurz.
|
||||
*
|
||||
* @param array<int, array{amount: float, currency: string, date?: string}> $items
|
||||
*/
|
||||
public function sumToCzk(array $items): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($items as $item) {
|
||||
$total += $this->toCzk(
|
||||
(float) $item['amount'],
|
||||
(string) $item['currency'],
|
||||
(string) ($item['date'] ?? '')
|
||||
);
|
||||
}
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{rate: float, amount: int}>
|
||||
*/
|
||||
private function getRatesForDate(string $date): array
|
||||
{
|
||||
if (isset($this->ratesByDate[$date])) {
|
||||
return $this->ratesByDate[$date];
|
||||
}
|
||||
|
||||
$rates = $this->fetchFromApi($date);
|
||||
if ($rates !== []) {
|
||||
$this->ratesByDate[$date] = $rates;
|
||||
$this->saveCache();
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
|
||||
// --- Cache ---
|
||||
|
||||
private function getCachePath(): string
|
||||
{
|
||||
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private function loadCache(): void
|
||||
{
|
||||
$path = $this->getCachePath();
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
foreach ($data as $date => $entry) {
|
||||
if (!is_array($entry) || !isset($entry['rates'], $entry['fetched_at'])) {
|
||||
continue;
|
||||
}
|
||||
// Dnesni kurz expiruje po TTL, starsi zustavaji navzdy
|
||||
if ($date === $today) {
|
||||
$age = time() - (int) $entry['fetched_at'];
|
||||
if ($age > self::TODAY_CACHE_TTL) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->ratesByDate[$date] = $entry['rates'];
|
||||
}
|
||||
}
|
||||
|
||||
private function saveCache(): void
|
||||
{
|
||||
$path = $this->getCachePath();
|
||||
|
||||
// Nacist existujici cache a mergovat
|
||||
$existing = [];
|
||||
if (file_exists($path)) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content !== false) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$existing = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
foreach ($this->ratesByDate as $date => $rates) {
|
||||
// Neprepisuj existujici pokud uz tam je (zachovej fetched_at)
|
||||
if (!isset($existing[$date])) {
|
||||
$existing[$date] = [
|
||||
'rates' => $rates,
|
||||
'fetched_at' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($existing, JSON_THROW_ON_ERROR);
|
||||
file_put_contents($path, $json, LOCK_EX);
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
/**
|
||||
* @return array<string, array{rate: float, amount: int}>
|
||||
*/
|
||||
private function fetchFromApi(string $date): array
|
||||
{
|
||||
$url = self::API_URL . '?lang=EN&date=' . urlencode($date);
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => 5],
|
||||
]);
|
||||
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
if ($response === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data) || !isset($data['rates'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
foreach ($data['rates'] as $entry) {
|
||||
if (!isset($entry['currencyCode'], $entry['rate'], $entry['amount'])) {
|
||||
continue;
|
||||
}
|
||||
$rates[$entry['currencyCode']] = [
|
||||
'rate' => (float) $entry['rate'],
|
||||
'amount' => (int) $entry['amount'],
|
||||
];
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user