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); } }