Update Harga Internasional

This commit is contained in:
2026-11-03 09:37:59 +07:00
parent 013cb7b728
commit c1370a26ca
10 changed files with 586 additions and 1 deletions
@@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers\Web\Dashboard;
use App\Http\Controllers\Controller;
use App\Services\AlphaVantageMarket;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Http;
use Throwable;
class InternationalMarketController extends Controller
{
public function __invoke()
{
abort_unless(Gate::allows('super_admin') || Gate::allows('admin') || Gate::allows('fasilitator'), 403);
// File cache keeps external quotes independent of the application database.
$cache = Cache::store('file');
$marketData = $cache->remember('market.yahoo.quotes.v3', 300, function () use ($cache) {
$commodities = [
['name' => 'Kopi Arabica', 'symbol' => 'KC=F'],
['name' => 'Kakao (Cocoa)', 'symbol' => 'CC=F'],
];
try {
$responses = Http::pool(function (Pool $pool) use ($commodities) {
return array_map(fn ($commodity) => $pool->as($commodity['symbol'])
->acceptJson()->withHeaders(['User-Agent' => 'Mozilla/5.0'])
->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true])
->connectTimeout(3)->timeout(8)
->get('https://query1.finance.yahoo.com/v8/finance/chart/'.rawurlencode($commodity['symbol']), [
'interval' => '1d', 'range' => '5d',
]), [...$commodities, ['symbol' => 'IDR=X']]);
});
} catch (Throwable $exception) {
report($exception);
$responses = [];
}
$exchangeRate = $cache->get('market.yahoo.usd_idr');
if (is_array($exchangeRate)) {
$exchangeRate['stale'] = true;
}
$rateResponse = $responses['IDR=X'] ?? null;
if ($rateResponse instanceof \Illuminate\Http\Client\Response && $rateResponse->successful()) {
$meta = $rateResponse->json('chart.result.0.meta');
if (is_array($meta) && ($meta['symbol'] ?? null) === 'IDR=X'
&& ($meta['currency'] ?? null) === 'IDR'
&& is_numeric($meta['regularMarketPrice'] ?? null) && $meta['regularMarketPrice'] > 0
&& is_numeric($meta['regularMarketTime'] ?? null)) {
$exchangeRate = [
'value' => (float) $meta['regularMarketPrice'],
'quoted_at' => gmdate('c', (int) $meta['regularMarketTime']),
'source' => 'Yahoo Finance',
'source_url' => 'https://finance.yahoo.com/quote/IDR%3DX/',
'stale' => false,
];
$cache->put('market.yahoo.usd_idr', $exchangeRate, now()->addDays(7));
}
}
$quotes = array_map(function ($commodity) use ($responses, $cache) {
$symbol = $commodity['symbol'];
$key = 'market.yahoo.last.'.rawurlencode($symbol);
$quote = $commodity + [
'source' => 'Yahoo Finance',
'source_url' => 'https://finance.yahoo.com/quote/'.rawurlencode($symbol).'/',
'price' => null, 'currency' => null, 'unit' => null,
'quoted_at' => null, 'stale' => false,
];
try {
$response = $responses[$symbol] ?? null;
if (!$response instanceof \Illuminate\Http\Client\Response || !$response->successful()) {
throw new \RuntimeException('Quote unavailable');
}
$meta = $response->json('chart.result.0.meta');
if ($response->json('chart.error') || !is_array($meta)
|| ($meta['symbol'] ?? null) !== $symbol
|| !is_numeric($meta['regularMarketPrice'] ?? null)
|| (float) $meta['regularMarketPrice'] <= 0
|| !is_numeric($meta['regularMarketTime'] ?? null)
|| !is_string($meta['currency'] ?? null)) {
throw new \RuntimeException('Invalid quote');
}
$quote['price'] = (float) $meta['regularMarketPrice'];
$quote['currency'] = $meta['currency'];
// Yahoo quotes Arabica in US cents/lb; cocoa and robusta in USD/tonne.
// Apply the unit only when the API currency matches that convention.
$quote['unit'] = match (true) {
$symbol === 'KC=F' && $meta['currency'] === 'USX' => 'sen AS / lb',
$symbol === 'CC=F' && $meta['currency'] === 'USD' => 'USD / ton metrik',
default => $meta['currency'].' (satuan mengikuti sumber)',
};
$quote['quoted_at'] = gmdate('c', (int) $meta['regularMarketTime']);
$cache->put($key, $quote, now()->addDays(7));
return $quote;
} catch (Throwable $exception) {
$previous = $cache->get($key);
if (is_array($previous)) {
$previous['stale'] = true;
return $previous;
}
return $quote;
}
}, $commodities);
return ['quotes' => $quotes, 'exchange_rate' => $exchangeRate];
});
$quotes = $marketData['quotes'];
$exchangeRate = $marketData['exchange_rate'];
foreach ($quotes as &$quote) {
$quote['id'] = 'yahoo:'.$quote['symbol'];
$quote['description'] = 'Estimasi konversi harga kontrak berjangka internasional.';
$quote['period'] = $quote['quoted_at'];
$quote['frequency'] = 'market';
}
unset($quote);
$quotes = array_merge($quotes, $this->imfQuotes($cache), app(AlphaVantageMarket::class)->quotes($cache));
$quotes = array_values(array_filter($quotes, fn ($quote) => is_numeric($quote['price']) && is_finite((float) $quote['price']) && $quote['price'] > 0));
foreach ($quotes as &$quote) {
// All active providers quote either US cents/lb or USD/metric tonne.
$usdPerKg = match ($quote['unit']) {
'sen AS / lb' => $quote['price'] / 100 / 0.45359237,
'USD / ton metrik' => $quote['price'] / 1000,
default => null,
};
$quote['price_idr_per_kg'] = $exchangeRate && $usdPerKg !== null
? round($usdPerKg * $exchangeRate['value'], 2) : null;
unset($quote['usd_per_kg']); // Old Alpha cache entries may contain this field.
}
unset($quote);
return response()->json([
'quotes' => $quotes,
'exchange_rate' => $exchangeRate,
])->header('Cache-Control', 'no-store');
}
private function imfQuotes(Repository $cache): array
{
// IMF observations are monthly averages; fetch at most once a day.
return $cache->remember('market.imf.monthly.v1', 86400, function () use ($cache) {
try {
$response = Http::withHeaders(['Accept' => 'text/csv', 'User-Agent' => 'Mozilla/5.0'])
->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true])
->connectTimeout(3)->timeout(10)
->get('https://api.imf.org/external/sdmx/2.1/data/IMF.RES,PCPS/G001.PCOFFOTM+PCOFFROB+PCOCO.USD.M', [
'startPeriod' => now()->subYears(2)->format('Y-m'), 'format' => 'csvfile',
])->throw();
$stream = fopen('php://memory', 'r+');
try {
fwrite($stream, $response->body());
rewind($stream);
$headers = fgetcsv($stream, 0, ',', '"', '');
if (!$headers || array_diff(['COUNTRY', 'INDICATOR', 'DATA_TRANSFORMATION', 'FREQUENCY', 'TIME_PERIOD', 'OBS_VALUE', 'SCALE'], $headers)) {
throw new \RuntimeException('Unexpected IMF CSV format');
}
$latest = [];
while (($values = fgetcsv($stream, 0, ',', '"', '')) !== false) {
if (count($values) !== count($headers)) continue;
$row = array_combine($headers, $values);
if ($row['COUNTRY'] !== 'G001' || $row['DATA_TRANSFORMATION'] !== 'USD' || $row['FREQUENCY'] !== 'M'
|| $row['SCALE'] !== '0' || !is_numeric($row['OBS_VALUE']) || $row['OBS_VALUE'] <= 0
|| !preg_match('/^\d{4}-M(0[1-9]|1[0-2])$/', $row['TIME_PERIOD'])) continue;
$indicator = $row['INDICATOR'];
if (!isset($latest[$indicator]) || $row['TIME_PERIOD'] > $latest[$indicator]['TIME_PERIOD']) {
$latest[$indicator] = $row;
}
}
} finally {
fclose($stream);
}
$definitions = [
'PCOFFOTM' => ['Kopi Arabica', 'USX', 'sen AS / lb', 'Biji kopi Arabica (Other Milds). Estimasi konversi harga acuan rata-rata bulanan IMF.'],
'PCOFFROB' => ['Kopi Robusta', 'USX', 'sen AS / lb', 'Biji kopi Robusta. Estimasi konversi harga acuan rata-rata bulanan IMF.'],
'PCOCO' => ['Kakao (Cocoa)', 'USD', 'USD / ton metrik', 'Biji kakao. Estimasi konversi harga acuan rata-rata bulanan IMF.'],
];
$quotes = [];
foreach ($definitions as $indicator => [$name, $currency, $unit, $description]) {
if (!isset($latest[$indicator])) continue;
$row = $latest[$indicator];
$quotes[] = [
'id' => 'imf:'.$indicator, 'symbol' => $indicator, 'name' => $name,
'price' => (float) $row['OBS_VALUE'], 'currency' => $currency, 'unit' => $unit,
'source' => 'IMF · PCPS', 'source_url' => 'https://data.imf.org/Datasets/PCPS',
'description' => $description, 'frequency' => 'monthly',
'period' => str_replace('-M', '-', $row['TIME_PERIOD']), 'stale' => false,
];
}
if (!$quotes) throw new \RuntimeException('No valid IMF observations');
$cache->put('market.imf.last.v1', $quotes, now()->addDays(30));
return $quotes;
} catch (Throwable $exception) {
report($exception);
return array_map(function ($quote) {
$quote['stale'] = true;
return $quote;
}, $cache->get('market.imf.last.v1', []));
}
});
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Http;
use Throwable;
class AlphaVantageMarket
{
public function quotes(Repository $cache): array
{
if (!$key = config('services.alpha_vantage.api_key')) {
return [];
}
// Retain existing cache keys so cleanup does not spend another API request.
$cacheKey = 'market.additional.v1.alpha:COFFEE';
$lastKey = 'market.additional.last.v1.alpha:COFFEE';
if (is_array($quotes = $cache->get($cacheKey))) {
return $quotes;
}
try {
$response = Http::acceptJson()
->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true])
->connectTimeout(3)->timeout(6)
->get('https://www.alphavantage.co/query', [
'function' => 'COFFEE', 'interval' => 'monthly', 'apikey' => $key,
])->throw();
$data = $response->json();
if (!is_array($data) || ($data['interval'] ?? null) !== 'monthly'
|| strtolower($data['unit'] ?? '') !== 'cents per pound' || !is_array($data['data'] ?? null)) {
throw new \RuntimeException('Invalid Alpha Vantage response');
}
$latest = null;
foreach ($data['data'] as $row) {
if (!is_array($row) || !is_numeric($row['value'] ?? null) || !is_finite((float) $row['value']) || $row['value'] <= 0
|| !is_string($row['date'] ?? null) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])-01$/', $row['date'])) {
continue;
}
if (!$latest || $row['date'] > $latest['date']) $latest = $row;
}
if (!$latest) throw new \RuntimeException('No valid Alpha Vantage price');
$quote = [
'id' => 'alpha:COFFEE', 'name' => 'Kopi Arabica',
'source' => 'Alpha Vantage (data IMF via FRED)',
'source_url' => 'https://www.alphavantage.co/documentation/#coffee',
'price' => (float) $latest['value'], 'currency' => 'USX', 'unit' => 'sen AS / lb',
'period' => substr($latest['date'], 0, 7), 'frequency' => 'monthly', 'stale' => false,
'description' => 'Biji kopi Arabica (Other Milds). Estimasi konversi harga acuan rata-rata bulanan IMF melalui Alpha Vantage.',
'update_info' => 'Data Alpha Vantage diperiksa setiap hari untuk menjaga kuota API. Harga merupakan rata-rata bulanan dari IMF via FRED.',
];
$cache->put($lastKey, $quote, now()->addDays(30));
$cache->put($cacheKey, [$quote], 86400);
return [$quote];
} catch (Throwable) {
// Request exceptions can contain the API key in their URL; do not log them.
$quotes = [];
if (is_array($last = $cache->get($lastKey))) {
$last['stale'] = true;
$quotes[] = $last;
}
$cache->put($cacheKey, $quotes, 3600);
return $quotes;
}
}
}