Files
finance_app/app/Http/Controllers/Api/SecurityPriceController.php
henry yo 85972f950c feat: 前端分離+功能api化
1. 新增資產管理CRUD、googleOAuth登入
2. 更改原記帳controller
2026-06-25 14:19:07 +08:00

270 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
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\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\InvestmentHolding;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
class SecurityPriceController extends Controller
{
/**
* 最新價格/現值 API (自帶過期即時回補機制 ⚡)
* GET /api/holdings/{holdingId}/realtime
*/
public function showRealtime(Request $request, string $holdingId)
{
// 1. 驗證持倉是否屬於該使用者
$holding = InvestmentHolding::whereHas('account', function ($q) use ($request) {
$q->where('user_id', $request->user()->id);
})->findOrFail($holdingId);
// 2. 從本地字典表撈出紀錄
$security = DB::table('securities')->where('code', $holding->symbol)->first();
// 💡 關鍵判定:如果字典表根本沒這檔標的,或者資料「過期了」,就即時去戳外網更新
$needUpdate = false;
if (!$security) {
$needUpdate = true;
} else {
$lastUpdate = Carbon::parse($security->updated_at);
// 情境 A最新價格的日期不是今天
if (!$lastUpdate->isToday()) {
$needUpdate = true;
}
// 情境 B如果是今天的台股交易時間 (09:00 - 14:00),且本地資料超過 15 分鐘沒更新,就幫他刷新
elseif (now()->between('09:00', '14:00') && now()->diffInMinutes($lastUpdate) > 15) {
$needUpdate = true;
}
}
if ($needUpdate) {
// 當場發動外網即時救援
$security = $this->fetchAndSaveRealtime($holding);
}
if (!$security) {
return response()->json(['error' => '無法取得該標的的最新價格資料'], 404);
}
// 3. 統一格式回傳給前端
return response()->json([
'symbol' => $holding->symbol,
'name' => $security->name,
'type' => $holding->type,
'latest_price' => (float) $security->latest_price,
'change_amount' => (float) ($security->change_amount ?? 0),
'change_rate' => (float) ($security->change_rate ?? 0),
'updated_at' => $security->updated_at,
]);
}
/**
* 內部方法:即時抓取外部市價並洗回資料庫字典表
*/
private function fetchAndSaveRealtime(InvestmentHolding $holding)
{
$priceData = [
'name' => $holding->name ?? $holding->symbol,
'latest_price' => 0.0,
'change_amount' => 0.0,
'change_rate' => 0.0,
];
// ----------------------------------------
// A. 股票實時抓取
// ----------------------------------------
if ($holding->type === 'stock') {
try {
$exchange = (str_starts_with($holding->symbol, '00') || strlen($holding->symbol) === 4) ? 'tse' : 'tse'; // 可依需求擴充 otc
$res = Http::timeout(5)->get("https://mis.twse.com.tw/stock/api/getStockInfo.jsp", [
'ex_ch' => "{$exchange}_{$holding->symbol}.tw",
'json' => 1,
'delay' => 0,
]);
if ($res->ok() && !empty($res->json('msgArray'))) {
$stock = $res->json('msgArray')[0];
if (isset($stock['^']) && !Carbon::parse($stock['^'])->isToday()) {
$existing = DB::table('securities')->where('code', $holding->symbol)->first();
if ($existing) return $existing;
}
$priceData['name'] = $stock['n'] ?? $priceData['name'];
$currentPrice = $stock['z'] !== '-' ? (float) $stock['z'] : (float) $stock['y'];
$prevPrice = (float) $stock['y'];
$priceData['latest_price'] = $currentPrice;
$priceData['change_amount'] = $currentPrice - $prevPrice;
$priceData['change_rate'] = $prevPrice > 0 ? (($currentPrice - $prevPrice) / $prevPrice) * 100 : 0;
}
} catch (\Exception $e) {
// 網路超時防呆,保留不崩潰
}
}
// ----------------------------------------
// B. 基金實時抓取
// ----------------------------------------
elseif ($holding->type === 'fund') {
try {
$res = Http::timeout(5)->get("https://www.anuefund.com/anuefundApi/FundDetail/FundInfo", [
'fundDetailEnum' => 'FundINFO',
'FundID' => $holding->symbol,
]);
if ($res->ok() && !empty($res->json('data.hearder'))) {
$header = $res->json('data.hearder');
if (isset($header['navDate']) && !Carbon::parse($header['navDate'])->isToday()) {
$existing = DB::table('securities')->where('code', $holding->symbol)->first();
if ($existing) return $existing;
}
$priceData['name'] = $header['fundName'] ?? $priceData['name'];
$priceData['latest_price'] = (float) $header['nav'];
// 鉅亨網實時 API 沒有給當日漲跌幅的話,這裡預設留空或 0
}
} catch (\Exception $e) {
// 防呆
}
}
// 如果連外部都抓不到且本地完全沒資料,就防呆返回 null
if ($priceData['latest_price'] == 0) {
$existing = DB::table('securities')->where('code', $holding->symbol)->first();
if ($existing) return $existing;
}
// 更新或新增進字典表
DB::table('securities')->updateOrInsert(
['code' => $holding->symbol],
[
'name' => $priceData['name'],
'type' => $holding->type,
'source' => $holding->type === 'stock' ? 'twse' : 'anue',
'latest_price' => $priceData['latest_price'],
'change_amount' => $priceData['change_amount'],
'change_rate' => $priceData['change_rate'],
'updated_at' => now(),
'created_at' => now(), // updateOrInsert 在更新時會自動忽略 created_at
]
);
return DB::table('securities')->where('code', $holding->symbol)->first();
}
/**
* 整合後的歷史紀錄 API (股票、基金共用)
* GET /api/holdings/{holdingId}/history
*/
public function history(Request $request, string $holdingId)
{
$holding = InvestmentHolding::whereHas('account', function ($q) use ($request) {
$q->where('user_id', $request->user()->id);
})->with(['transactions'])->findOrFail($holdingId);
// 找出最早的買入點作為時間起點
$firstTx = $holding->transactions->where('action', 'buy')->sortBy('traded_at')->first();
if (!$firstTx) return response()->json(['history' => [], 'buy_points' => []]);
$startDate = Carbon::parse($firstTx->traded_at)->toDateString();
// 拿持倉的 symbol 去字典表查出對應的歷史主鍵 id
$securityId = DB::table('securities')->where('code', $holding->symbol)->value('id');
if (!$securityId) {
return response()->json(['history' => [], 'buy_points' => []]);
}
// 從本地歷史資料庫撈取趨勢
$history = DB::table('security_price_histories')
->where('security_id', $securityId)
->where('price_date', '>=', $startDate)
->orderBy('price_date', 'asc')
->get(['price_date as date', 'price as close'])
->map(function ($item) {
return [
'date' => $item->date,
'close' => (float) $item->close
];
})
->toArray();
// 整合買賣點標記
$buyPoints = $holding->transactions
->where('action', 'buy')
->map(fn($tx) => [
'date' => Carbon::parse($tx->traded_at)->toDateString(),
'price' => (float) ($holding->type === 'fund'
? ($tx->nav ?? $tx->price ?? 0)
: ($tx->price ?? $tx->nav ?? 0)),
'note' => $tx->note ?? '買入',
])->values();
return response()->json([
'history' => $history,
'buy_points' => $buyPoints,
]);
}
public function accountTrend(Request $request, string $accountId)
{
$account = $request->user()->accounts()->findOrFail($accountId);
// 1. 撈出所有目前有庫存的持倉
$holdings = $account->holdings()->where('shares', '>', 0)->get();
// 🟢 關鍵修正:找出所有有庫存標的裡,最古老的一筆買入交易日期
$earliestTxDate = DB::table('investment_transactions')
->whereIn('holding_id', $holdings->pluck('id'))
->where('action', 'buy')
->orderBy('traded_at', 'asc')
->value('traded_at');
// 防呆:如果真的完全沒有交易紀錄,預設才走 30 天;有的話就以最古老日期為起點
$startDate = $earliestTxDate
? Carbon::parse($earliestTxDate)->toDateString()
: now()->subDays(30)->format('Y-m-d');
$securityIds = DB::table('securities')->whereIn('code', $holdings->pluck('symbol'))->pluck('id', 'code');
// 2. 撈取歷史紀錄 (此時起點已經完美回溯到 4 月甚至更早了)
$historyPrices = DB::table('security_price_histories')
->whereIn('security_id', $securityIds->values())
->where('price_date', '>=', $startDate)
->orderBy('price_date', 'asc')
->get()
->groupBy('security_id');
$chartData = [];
foreach ($holdings as $holding) {
$sid = $securityIds->get($holding->symbol);
if (!$sid) continue;
$prices = $historyPrices->get($sid) ?? collect();
$avgCost = floatval($holding->avg_cost);
if ($avgCost <= 0) continue;
$seriesData = [];
foreach ($prices as $hp) {
$dailyRate = ((floatval($hp->price) - $avgCost) / $avgCost) * 100;
$seriesData[] = [
'date' => $hp->price_date,
'rate' => round($dailyRate, 2)
];
}
$chartData[] = [
'symbol' => $holding->symbol,
'name' => $holding->name,
'data' => $seriesData
];
}
return response()->json($chartData);
}
}