mirror of
https://github.com/henry4682/finance_app.git
synced 2026-07-16 00:10:00 +00:00
feat: 前端分離+功能api化
1. 新增資產管理CRUD、googleOAuth登入 2. 更改原記帳controller
This commit is contained in:
23
app/Console/Commands/DebugGmailJob.php
Normal file
23
app/Console/Commands/DebugGmailJob.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Google\Client as GoogleClient;
|
||||
|
||||
use App\Jobs\FetchAndParseGmailInvoices;
|
||||
|
||||
class DebugGmailJob extends Command
|
||||
{
|
||||
protected $signature = 'gmail:debug-job';
|
||||
protected $description = '繞過Job快取,直接在終端機打印最新未讀信件';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info("🔄 正在讀取 User ID 1 的 Google 憑證...");
|
||||
$user = User::find(1);
|
||||
|
||||
app(FetchAndParseGmailInvoices::class, ['user' => $user])->handle(new \App\Services\GmailParserService());
|
||||
}
|
||||
}
|
||||
162
app/Console/Commands/FetchGmailInvoices.php
Normal file
162
app/Console/Commands/FetchGmailInvoices.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\User;
|
||||
use App\Models\Expense;
|
||||
use App\Services\GmailParserService;
|
||||
use Google\Client as GoogleClient;
|
||||
use Google\Service\Gmail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class FetchAndParseGmailInvoices implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $user;
|
||||
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function handle(GmailParserService $parserService)
|
||||
{
|
||||
Log::info("====== [DEBUG START] 開始執行 Gmail 抓信任務 ======");
|
||||
|
||||
if (!$this->user->google_refresh_token) {
|
||||
Log::error("❌ 失敗:User ID {$this->user->id} 缺乏 google_refresh_token");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Log::info("🔄 正在初始化 Google Client & 刷新 Token...");
|
||||
$client = new GoogleClient();
|
||||
$client->setClientId(config('services.google.client_id'));
|
||||
$client->setClientSecret(config('services.google.client_secret'));
|
||||
$client->refreshToken($this->user->google_refresh_token);
|
||||
$gmailService = new Gmail($client);
|
||||
|
||||
$afterDate = Carbon::now()->subDays(3)->format('Y/m/d');
|
||||
$searchQuery = "from:cathaybk.com.tw subject:消費彙整通知 after:{$afterDate}";
|
||||
Log::info("🔍 正在向 Google API 發送搜尋 Query: [{$searchQuery}]");
|
||||
|
||||
$response = $gmailService->users_messages->listUsersMessages('me', [
|
||||
'q' => $searchQuery,
|
||||
'maxResults' => 10
|
||||
]);
|
||||
|
||||
$messages = $response->getMessages();
|
||||
|
||||
if (empty($messages)) {
|
||||
Log::warning("⚠️ 警告:Google API 回傳空陣列!");
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info("🎯 成功拿到信件清單!總共發現 " . count($messages) . " 封潛在信件,開始逐封處理...");
|
||||
|
||||
foreach ($messages as $index => $msgItem) {
|
||||
$msgId = $msgItem->getId();
|
||||
Log::info("--- [處理第 " . ($index + 1) . " 封信] ID: {$msgId} ---");
|
||||
|
||||
$message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']);
|
||||
$payload = $message->getPayload();
|
||||
|
||||
$subject = '';
|
||||
foreach ($payload->getHeaders() as $header) {
|
||||
if ($header->getName() === 'Subject') {
|
||||
$subject = $header->getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Log::info("📬 信件主旨: [{$subject}]");
|
||||
|
||||
$body = $this->getDecodedBody($payload);
|
||||
Log::info("📄 內文解碼完成,準備丟入 Parser...");
|
||||
|
||||
// 1. 執行解析(現在國泰會回報陣列,其餘單筆通知我們手動把它包成陣列)
|
||||
$parsedResult = $parserService->parse($subject, $body);
|
||||
|
||||
if (!$parsedResult) {
|
||||
Log::error("❌ 失敗:GmailParserService 無法解析此信件內容");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 💡 智慧防呆:如果回傳的是單筆消費(一維陣列),我們把它包成二維陣列,這樣下面統一用 foreach 處理
|
||||
$expensesList = isset($parsedResult['amount']) ? [$parsedResult] : $parsedResult;
|
||||
|
||||
Log::info("✨ Parser 解析成功!共發現 " . count($expensesList) . " 筆刷卡消費項目,開始遍歷寫入...");
|
||||
|
||||
// 🔄 遍歷這封信裡面的每一筆刷卡紀錄
|
||||
foreach ($expensesList as $parsedData) {
|
||||
|
||||
// 🟢 2. 智慧資產帳戶對照
|
||||
$matchedAccountRule = \App\Models\AccountRule::where('user_id', $this->user->id)
|
||||
->get()
|
||||
->first(function ($rule) use ($subject) {
|
||||
return Str::contains($subject, $rule->keyword);
|
||||
});
|
||||
$accountId = $matchedAccountRule ? $matchedAccountRule->account_id : null;
|
||||
|
||||
// 🟢 3. 智慧消費分類對照
|
||||
$matchedCategoryRule = \App\Models\CategoryRule::where('user_id', $this->user->id)
|
||||
->get()
|
||||
->first(function ($rule) use ($parsedData) {
|
||||
return Str::contains(strtoupper($parsedData['seller_name']), strtoupper($rule->keyword));
|
||||
});
|
||||
$categoryId = $matchedCategoryRule ? $matchedCategoryRule->category_id : null;
|
||||
|
||||
// 4. 💾 嘗試寫入資料庫
|
||||
$expense = Expense::firstOrCreate(
|
||||
[
|
||||
'user_id' => $this->user->id,
|
||||
'amount' => $parsedData['amount'],
|
||||
'seller_name' => $parsedData['seller_name'],
|
||||
'date' => $parsedData['date'],
|
||||
],
|
||||
[
|
||||
'item_name' => $parsedData['item_name'],
|
||||
'account_id' => $accountId,
|
||||
'category_id' => $categoryId,
|
||||
'note' => 'Gmail自動同步(多筆完全體)',
|
||||
]
|
||||
);
|
||||
|
||||
if ($expense->wasRecentlyCreated) {
|
||||
Log::info("✅ 成功!建立全新消費: [{$parsedData['seller_name']}] 額度: [{$parsedData['amount']}], ID: {$expense->id}");
|
||||
} else {
|
||||
Log::info("ℹ️ 提示:[{$parsedData['seller_name']}] 額度: [{$parsedData['amount']}] 已存在,跳過。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("====== [DEBUG END] Gmail 抓信任務順利結束 ======");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("💥 崩潰!handle 內發生致命錯誤: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getDecodedBody($part)
|
||||
{
|
||||
$body = $part->getBody();
|
||||
if ($body && $body->getData()) {
|
||||
return base64_decode(str_replace(['-', '_'], ['+', '/'], $body->getData()));
|
||||
}
|
||||
$parts = $part->getParts();
|
||||
if ($parts) {
|
||||
foreach ($parts as $subPart) {
|
||||
$result = $this->getDecodedBody($subPart);
|
||||
if ($result) return $result;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/RefreshAllAssetPrices.php
Normal file
40
app/Console/Commands/RefreshAllAssetPrices.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\UpdateAssetPriceJob;
|
||||
use App\Models\InvestmentHolding;
|
||||
use App\Models\Security;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshAllAssetPrices extends Command
|
||||
{
|
||||
// 執行此指令的名稱:php artisan asset:refresh-prices
|
||||
protected $signature = 'asset:refresh-prices';
|
||||
protected $description = '將全系統所有股票與基金的價格更新任務推入 Queue 佇列';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// 🟢 1. 從持倉表中撈出所有使用者「目前有在使用」且「不重複」的股票與基金代號
|
||||
// 這樣能完美確保 2330 只會被爬一次,不會對證交所造成重複負擔
|
||||
$uniqueHoldings = Security::select('code', 'type', 'name')
|
||||
->groupBy('code', 'type', 'name') // 去除重複
|
||||
->get();
|
||||
|
||||
$this->info("全系統共發現 " . $uniqueHoldings->count() . " 檔不重複的現役標的,開始指派更新任務...");
|
||||
|
||||
$delaySeconds = 0;
|
||||
|
||||
foreach ($uniqueHoldings as $holding) {
|
||||
UpdateAssetPriceJob::dispatch(
|
||||
$holding->code,
|
||||
$holding->type,
|
||||
$holding->name
|
||||
)->delay(now()->addSeconds($delaySeconds));
|
||||
|
||||
$delaySeconds += 2;
|
||||
}
|
||||
|
||||
$this->info("所有任務皆已成功排入背景 Queue,預計花費 {$delaySeconds} 秒消化完畢。");
|
||||
}
|
||||
}
|
||||
115
app/Console/Commands/TestGmailFetch.php
Normal file
115
app/Console/Commands/TestGmailFetch.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Google\Client as GoogleClient;
|
||||
use Google\Service\Gmail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TestGmailFetch extends Command
|
||||
{
|
||||
protected $signature = 'gmail:test-fetch';
|
||||
protected $description = '測試抓取最新的一筆發票或刷卡信件,並輸出至 Log 中';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// 1. 抓取你剛才綁定成功的使用者(假設 ID 為 1)
|
||||
$user = User::find(1);
|
||||
|
||||
if (!$user || !$user->google_refresh_token) {
|
||||
$this->error('找不到使用者,或該使用者尚未擁有 google_refresh_token!');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("🔄 正在初始化 Google Client 并刷新 Token...");
|
||||
|
||||
// 2. 初始化 Google Client
|
||||
$client = new GoogleClient();
|
||||
$client->setClientId(config('services.google.client_id'));
|
||||
$client->setClientSecret(config('services.google.client_secret'));
|
||||
|
||||
// 用 refresh_token 去跟 Google 交換新的短期 access_token
|
||||
$client->refreshToken($user->google_refresh_token);
|
||||
$accessToken = $client->getAccessToken();
|
||||
|
||||
// 順手把新的 token 蓋回資料庫(防呆)
|
||||
$user->update([
|
||||
'google_access_token' => $accessToken['access_token'],
|
||||
'google_token_expires_at' => now()->addSeconds($accessToken['expires_in']),
|
||||
]);
|
||||
|
||||
// 3. 呼叫 Gmail 服務
|
||||
$gmailService = new Gmail($client);
|
||||
|
||||
$this->info("🔍 正在透過標籤精準搜尋信用卡消費通知...");
|
||||
|
||||
$searchQuery = 'label:信用卡 -is:spam -is:trash';
|
||||
|
||||
$response = $gmailService->users_messages->listUsersMessages('me', [
|
||||
'maxResults' => 1, // 先抓一筆來看內文
|
||||
'q' => $searchQuery
|
||||
]);
|
||||
|
||||
$messages = $response->getMessages();
|
||||
|
||||
if (empty($messages)) {
|
||||
$this->warn('❌ 找不到任何符合關鍵字的信件!請確認你的 Gmail 裡有這種類型的信。');
|
||||
return;
|
||||
}
|
||||
|
||||
$msgId = $messages[0]->getId();
|
||||
$this->info("🎯 成功找到信件 ID: {$msgId},正在下載詳細內容...");
|
||||
|
||||
// 4. 抓取信件完整細節
|
||||
$message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']);
|
||||
$payload = $message->getPayload();
|
||||
$headers = $payload->getHeaders();
|
||||
|
||||
// 解析主旨
|
||||
$subject = '';
|
||||
foreach ($headers as $header) {
|
||||
if ($header->getName() === 'Subject') {
|
||||
$subject = $header->getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析內文 (處理可能存在的多重 Multipart 結構)
|
||||
$body = $this->getDecodedBody($payload);
|
||||
|
||||
// 5. 🟢 核心現形:把內容原封不動地噴進 Log
|
||||
Log::info("=== 【GMAIL 測試抓取成功】 ===");
|
||||
Log::info("主旨: " . $subject);
|
||||
Log::info("信件 ID: " . $msgId);
|
||||
Log::info("=== 原始內文開始 ===");
|
||||
Log::info($body);
|
||||
Log::info("=== 原始內文結束 ===");
|
||||
|
||||
$this->info("✅ 抓取完畢!真實內容已成功寫入 storage/logs/laravel.log,快去打開瞧瞧吧!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 輔助方法:遞迴解析 Gmail 的 Body 內容
|
||||
*/
|
||||
private function getDecodedBody($part)
|
||||
{
|
||||
$body = $part->getBody();
|
||||
if ($body && $body->getData()) {
|
||||
// Gmail 的 base64 比較特殊,需要把 - 和 _ 換回來
|
||||
$sanitizedData = str_replace(['-', '_'], ['+', '/'], $body->getData());
|
||||
return base64_decode($sanitizedData);
|
||||
}
|
||||
|
||||
$parts = $part->getParts();
|
||||
if ($parts) {
|
||||
foreach ($parts as $subPart) {
|
||||
$result = $this->getDecodedBody($subPart);
|
||||
if ($result) return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/Api/AccountController.php
Normal file
122
app/Http/Controllers/Api/AccountController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 🟢 1. 預先載入 holdings、transactions 還有全新開荒好的 security 字典表!
|
||||
$accounts = $request->user()->accounts()
|
||||
->with(['holdings.transactions', 'holdings.security']) // 👈 確保能直接拿到最新價格
|
||||
->get()
|
||||
->map(function ($account) {
|
||||
|
||||
$data = $account->toArray();
|
||||
|
||||
// 🟢 2. 處理股票與基金的成本與估計現值計算
|
||||
if (in_array($account->type, ['stock', 'fund'])) {
|
||||
|
||||
$totalCost = 0;
|
||||
$totalValue = 0;
|
||||
|
||||
foreach ($account->holdings as $holding) {
|
||||
|
||||
// 🟢 修正 1:直接拿資料庫存好的庫存股數,不用再去 transactions 加減計算
|
||||
$remainingShares = floatval($holding->shares);
|
||||
|
||||
if ($remainingShares > 0) {
|
||||
|
||||
// 🟢 修正 2:拿到最新價格 (如果沒抓到就先給一個暫時的預設值,例如 10,方便你 debug 畫面)
|
||||
$currentPrice = floatval($holding->security?->latest_price ?? 0);
|
||||
|
||||
if ($currentPrice == 0) {
|
||||
// 💡 Debug 觀測:如果還是 0,我們先用 avg_cost 頂替,或者去手動確認 securities 表有沒有真的更新到價格
|
||||
$currentPrice = floatval($holding->avg_cost ?? 0);
|
||||
}
|
||||
|
||||
// 總現值 = 剩餘單位 * 最新價格
|
||||
$totalValue += ($remainingShares * $currentPrice);
|
||||
|
||||
// 總成本:直接拿你畫面上已經有的 avg_cost * shares 算最快最準!
|
||||
$avgCost = floatval($holding->avg_cost ?? 0);
|
||||
$totalCost += ($remainingShares * $avgCost);
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 捨入並塞入回傳陣列
|
||||
$data['total_cost'] = round($totalCost); // 持倉成本
|
||||
$data['total_value'] = round($totalValue); // 目前市值
|
||||
|
||||
// 💡 關鍵補強:計算總損益與總報酬率並塞回陣列,消滅前端的 NaN%!
|
||||
$totalProfit = $totalValue - $totalCost;
|
||||
$totalRate = $totalCost > 0 ? ($totalProfit / $totalCost) * 100 : 0;
|
||||
|
||||
// 🚨 請確認這裡的 Key 名稱(例如 total_profit 或是 unrealized_profit),
|
||||
// 要跟你在 Vue 前端 account.xxx 綁定的欄位名稱一模一樣喔!
|
||||
$data['total_profit'] = round($totalProfit);
|
||||
$data['total_rate'] = round($totalRate, 2);
|
||||
}
|
||||
|
||||
// 處理信用卡
|
||||
if ($account->type === 'credit_card') {
|
||||
$data['unpaid_amount'] = $account->unpaidAmount();
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return response()->json($accounts);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:cash,bank,stock,fund,wallet,credit_card',
|
||||
'currency' => 'nullable|string|max:10',
|
||||
'balance' => 'nullable|numeric',
|
||||
'note' => 'nullable|string',
|
||||
'billing_day' => 'nullable|integer|min:1|max:31',
|
||||
'payment_day' => 'nullable|integer|min:1|max:31',
|
||||
]);
|
||||
|
||||
$account = $request->user()->accounts()->create($validated);
|
||||
return response()->json($account, 201);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($id);
|
||||
return response()->json($account);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'type' => 'sometimes|in:cash,bank,stock,fund,wallet,credit_card',
|
||||
'currency' => 'nullable|string|max:10',
|
||||
'balance' => 'nullable|numeric',
|
||||
'note' => 'nullable|string',
|
||||
'billing_day' => 'nullable|integer|min:1|max:31',
|
||||
'payment_day' => 'nullable|integer|min:1|max:31',
|
||||
]);
|
||||
|
||||
$account->update($validated);
|
||||
return response()->json($account);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($id);
|
||||
$account->delete();
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,6 +11,10 @@ class CategoryController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* 新增分類
|
||||
* POST /api/categories
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
@@ -18,14 +23,19 @@ class CategoryController extends Controller
|
||||
'icon' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
Category::create([
|
||||
$category = Category::create([
|
||||
...$validated,
|
||||
'user_id' => auth()->id(),
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return redirect()->back();
|
||||
// 🟢 修正:回傳剛剛建立的 JSON 資料與 201 狀態碼給前端 push 入陣列
|
||||
return response()->json($category, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改分類
|
||||
* PUT /api/categories/{category}
|
||||
*/
|
||||
public function update(Request $request, Category $category)
|
||||
{
|
||||
$this->authorize('update', $category);
|
||||
@@ -36,13 +46,20 @@ class CategoryController extends Controller
|
||||
]);
|
||||
|
||||
$category->update($validated);
|
||||
return redirect()->back();
|
||||
|
||||
// 🟢 修正:回傳修改後的最新資料,讓前端動態替換
|
||||
return response()->json($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除分類
|
||||
* DELETE /api/categories/{category}
|
||||
*/
|
||||
public function destroy(Category $category)
|
||||
{
|
||||
$this->authorize('delete', $category);
|
||||
$category->delete();
|
||||
return redirect()->back();
|
||||
|
||||
return response()->json(['message' => '分類刪除成功']);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Api/CategoryRuleController.php
Normal file
50
app/Http/Controllers/Api/CategoryRuleController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CategoryRule;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
class CategoryRuleController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* 新增自動分類規則
|
||||
* POST /api/category-rules
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'keyword' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
]);
|
||||
|
||||
$rule = CategoryRule::create([
|
||||
...$validated,
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// 🟢 核心優化:必須預先載入 category 關聯,否則前端拿到這筆規則物件時,會無法渲染它的分類標籤
|
||||
$rule->load('category');
|
||||
|
||||
return response()->json($rule, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除自動分類規則
|
||||
* DELETE /api/category-rules/{categoryRule}
|
||||
*/
|
||||
public function destroy(CategoryRule $categoryRule)
|
||||
{
|
||||
if ($categoryRule->user_id !== $request->user()->id) {
|
||||
return response()->json(['error' => '未經授權'], 403);
|
||||
}
|
||||
|
||||
$categoryRule->delete();
|
||||
|
||||
return response()->json(['message' => '自動分類規則刪除成功']);
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/Api/DashboardController.php
Normal file
114
app/Http/Controllers/Api/DashboardController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Expense;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Carbon\Carbon;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$now = Carbon::now();
|
||||
|
||||
// 1. 本月基礎統計 (用一個查詢同時撈出「總金額」與「總筆數」)
|
||||
$monthStats = Expense::where('user_id', $userId)
|
||||
->whereYear('date', $now->year)
|
||||
->whereMonth('date', $now->month)
|
||||
->selectRaw('SUM(amount) as total_amount, COUNT(id) as total_count')
|
||||
->first();
|
||||
|
||||
$monthlyTotal = (float) ($monthStats->total_amount ?? 0);
|
||||
$monthlyCount = (int) ($monthStats->total_count ?? 0);
|
||||
|
||||
// 2. 本月分類統計(圓餅圖)- 保持集合操作即可
|
||||
$categoryStats = Expense::with('category')
|
||||
->where('user_id', $userId)
|
||||
->whereYear('date', $now->year)
|
||||
->whereMonth('date', $now->month)
|
||||
->get()
|
||||
->groupBy(fn($e) => $e->category?->name ?? '未分類')
|
||||
->map(fn($group, $name) => [
|
||||
'name' => $name,
|
||||
'total' => (float) $group->sum('amount'),
|
||||
'color' => $group->first()->category?->color ?? '#6B7280',
|
||||
])
|
||||
->values();
|
||||
|
||||
// 3. 近 6 個月趨勢優化:一次性撈出半年內的所有資料,避免 6 次 SQL 查詢
|
||||
$sixMonthsAgo = $now->copy()->subMonths(5)->startOfMonth();
|
||||
$endOfCurrentMonth = $now->copy()->endOfMonth();
|
||||
|
||||
// 建立近 6 個月的初始月份清單(例如 ['2026/01' => 0.0, '2026/02' => 0.0 ... ])
|
||||
$monthlyTrendData = collect(range(5, 0))->mapWithKeys(function ($i) use ($now) {
|
||||
$monthStr = $now->copy()->subMonths($i)->format('Y/m');
|
||||
return [$monthStr => 0.0];
|
||||
})->toArray();
|
||||
|
||||
// ➔ (A) 撈取一般消費 (未開啟分攤)
|
||||
$isPostgres = DB::connection()->getDriverName() === 'pgsql';
|
||||
$dateGroupRaw = $isPostgres ? "to_char(date, 'YYYY/MM')" : "DATE_FORMAT(date, '%Y/%m')";
|
||||
|
||||
$normalExpenses = Expense::where('user_id', $userId)
|
||||
->where('is_amortized', false)
|
||||
->where('date', '>=', $sixMonthsAgo)
|
||||
->selectRaw("{$dateGroupRaw} as month, SUM(amount) as total")
|
||||
->groupBy('month')
|
||||
->get();
|
||||
|
||||
foreach ($normalExpenses as $expense) {
|
||||
if (array_key_exists($expense->month, $monthlyTrendData)) {
|
||||
$monthlyTrendData[$expense->month] += (float) $expense->total;
|
||||
}
|
||||
}
|
||||
|
||||
// ➔ (B) 智慧分攤消費 (處理跨月、跨年、半年繳大魔王 🎯)
|
||||
// 撈出所有歸屬期間有交集到這 6 個月範圍內的分攤費用
|
||||
$amortizedExpenses = Expense::where('user_id', $userId)
|
||||
->where('is_amortized', true)
|
||||
->where(function ($query) use ($sixMonthsAgo, $endOfCurrentMonth) {
|
||||
$query->where('period_start', '<=', $endOfCurrentMonth)
|
||||
->where('period_end', '>=', $sixMonthsAgo);
|
||||
})
|
||||
->get();
|
||||
|
||||
foreach ($amortizedExpenses as $expense) {
|
||||
$start = Carbon::parse($expense->period_start)->startOfMonth();
|
||||
$end = Carbon::parse($expense->period_end)->endOfMonth();
|
||||
|
||||
// 計算這筆費用的總跨月份數 (至少為 1 個月)
|
||||
$totalMonths = max(1, $start->diffInMonths($end) + 1);
|
||||
// 算出每月平均攤提金額
|
||||
$monthlyShare = (float) $expense->amount / $totalMonths;
|
||||
|
||||
// 跑這 6 個月的迴圈,看有沒有落在這筆費用的歸屬區間內
|
||||
foreach ($monthlyTrendData as $monthStr => $currentTotal) {
|
||||
$currentMonthObj = Carbon::createFromFormat('Y/m', $monthStr)->startOfMonth();
|
||||
|
||||
// 如果圖表上的這個月份,剛好在費用的歸屬起訖之內,就把每月平均金額灌進去!
|
||||
if ($currentMonthObj->betweenIncluded($start, $end)) {
|
||||
$monthlyTrendData[$monthStr] += $monthlyShare;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 轉回前端圖表要的格式
|
||||
$monthlyTrend = collect($monthlyTrendData)->map(function ($total, $monthStr) {
|
||||
return [
|
||||
'month' => $monthStr,
|
||||
'total' => round($total, 2), // 四捨五入到小數點兩位
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'monthlyTotal' => $monthlyTotal,
|
||||
'monthlyCount' => $monthlyCount,
|
||||
'categoryStats' => $categoryStats,
|
||||
'monthlyTrend' => $monthlyTrend,
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/Api/ExpenseController.php
Normal file
136
app/Http/Controllers/Api/ExpenseController.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api; // 💡 建議放到 Api 命名空間下
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryRule;
|
||||
use App\Models\Expense;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ExpenseController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* 1. 取得使用者所有支出明細
|
||||
* GET /api/expenses
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$expenses = Expense::with('category')
|
||||
->where('user_id', auth()->id())
|
||||
->orderBy('date', 'desc')
|
||||
->get();
|
||||
|
||||
return response()->json($expenses);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 取得使用者所有的消費分類
|
||||
* GET /api/categories
|
||||
*/
|
||||
public function categories()
|
||||
{
|
||||
$categories = Category::where('user_id', auth()->id())->get();
|
||||
return response()->json($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 取得使用者所有的自動分類規則
|
||||
* GET /api/category-rules
|
||||
*/
|
||||
public function categoryRules()
|
||||
{
|
||||
$rules = CategoryRule::with('category')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
|
||||
return response()->json($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. 手動新增支出明細
|
||||
* POST /api/expenses
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'item_name' => 'nullable|string|max:255',
|
||||
'seller_name' => 'nullable|string|max:255',
|
||||
'is_amortized' => 'boolean',
|
||||
'period_start' => 'nullable|date',
|
||||
'period_end' => 'nullable|date|after_or_equal:period_start',
|
||||
'date' => 'required|date',
|
||||
'note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$expense = Expense::create([
|
||||
...$validated,
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// 🟢 核心優化:載入分類關聯,這樣前端拿到的 response.data 才能立刻顯示分類顏色
|
||||
$expense->load('category');
|
||||
|
||||
return response()->json($expense, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. 修改單筆支出
|
||||
* PUT /api/expenses/{expense}
|
||||
*/
|
||||
public function update(Request $request, Expense $expense)
|
||||
{
|
||||
$this->authorize('update', $expense);
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'item_name' => 'nullable|string|max:255',
|
||||
'date' => 'required|date',
|
||||
'note' => 'nullable|string|max:255',
|
||||
'seller_name' => 'nullable|string|max:255',
|
||||
'is_amortized' => 'boolean',
|
||||
'period_start' => 'nullable|date',
|
||||
'period_end' => 'nullable|date|after_or_equal:period_start',
|
||||
]);
|
||||
|
||||
$expense->update($validated);
|
||||
|
||||
// 🟢 核心優化:同樣載入分類關聯
|
||||
$expense->load('category');
|
||||
|
||||
return response()->json($expense);
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. 刪除單筆支出
|
||||
* DELETE /api/expenses/{expense}
|
||||
*/
|
||||
public function destroy(Expense $expense)
|
||||
{
|
||||
$this->authorize('delete', $expense);
|
||||
$expense->delete();
|
||||
|
||||
return response()->json(['message' => '刪除成功']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. 批量刪除支出
|
||||
* POST /api/expenses/destroy-batch
|
||||
*/
|
||||
public function destroyBatch(Request $request)
|
||||
{
|
||||
$request->validate(['ids' => 'required|array']);
|
||||
|
||||
Expense::whereIn('id', $request->ids)
|
||||
->where('user_id', $request->user()->id)
|
||||
->delete();
|
||||
|
||||
return response()->json(['message' => '批量刪除成功']);
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/Api/InvestmentHoldingController.php
Normal file
123
app/Http/Controllers/Api/InvestmentHoldingController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\UpdateAssetPriceJob;
|
||||
use App\Models\Account;
|
||||
use App\Models\InvestmentHolding;
|
||||
use App\Models\Security;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InvestmentHoldingController extends Controller
|
||||
{
|
||||
public function index(Request $request, string $accountId)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($accountId);
|
||||
|
||||
// 🟢 1. 預先載入最新價格字典表關聯,避免 N+1 問題
|
||||
$holdings = $account->holdings()->with('security')->get();
|
||||
|
||||
// 🟢 2. 透過 map 幫每檔持倉加工算出市值與報酬率
|
||||
$processedHoldings = $holdings->map(function ($holding) {
|
||||
|
||||
$data = $holding->toArray();
|
||||
|
||||
$remainingShares = floatval($holding->shares);
|
||||
$avgCost = floatval($holding->avg_cost);
|
||||
|
||||
// 預設給 0 或拿平均成本防呆
|
||||
$currentPrice = floatval($holding->security?->latest_price ?? 0);
|
||||
if ($currentPrice == 0) {
|
||||
$currentPrice = $avgCost;
|
||||
}
|
||||
|
||||
if ($remainingShares > 0) {
|
||||
// 單一標的總成本
|
||||
$totalCost = $remainingShares * $avgCost;
|
||||
// 單一標目前市值
|
||||
$currentValue = $remainingShares * $currentPrice;
|
||||
// 單一標的未實現損益
|
||||
$profit = $currentValue - $totalCost;
|
||||
// 單一標的報酬率
|
||||
$profitRate = $totalCost > 0 ? ($profit / $totalCost) * 100 : 0;
|
||||
|
||||
// 🟢 3. 塞入擴充數據欄位
|
||||
$data['latest_price'] = $currentPrice;
|
||||
$data['current_value'] = round($currentValue);
|
||||
$data['profit'] = round($profit);
|
||||
$data['profit_rate'] = round($profitRate, 2);
|
||||
} else {
|
||||
// 庫存為 0 的防呆
|
||||
$data['latest_price'] = $currentPrice;
|
||||
$data['current_value'] = 0;
|
||||
$data['profit'] = 0;
|
||||
$data['profit_rate'] = 0;
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
// 傳回包含豐富績效數據的持倉清單
|
||||
return response()->json($processedHoldings);
|
||||
}
|
||||
|
||||
public function store(Request $request, string $accountId)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($accountId);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:stock,fund',
|
||||
'symbol' => 'required|string|max:20',
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$holding = $account->holdings()->create($validated);
|
||||
|
||||
// 檢查security: 如果之前有資料不create, 如果之前沒有資料create
|
||||
$security = Security::firstOrCreate(
|
||||
['code' => $validated['symbol']], // 尋找條件
|
||||
[
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'source' => $request->input('source') ?? 'twse',
|
||||
]
|
||||
);
|
||||
|
||||
// 立即派發更新價格的任務
|
||||
UpdateAssetPriceJob::dispatch($security->code, $security->type, $security->name);
|
||||
|
||||
return response()->json($holding, 201);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $accountId, string $id)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($accountId);
|
||||
$holding = $account->holdings()->findOrFail($id);
|
||||
return response()->json($holding);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $accountId, string $id)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($accountId);
|
||||
$holding = $account->holdings()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
]);
|
||||
|
||||
$holding->update($validated);
|
||||
|
||||
return response()->json($holding);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $accountId, string $id)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($accountId);
|
||||
$holding = $account->holdings()->findOrFail($id);
|
||||
$holding->delete();
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
98
app/Http/Controllers/Api/InvestmentTransactionController.php
Normal file
98
app/Http/Controllers/Api/InvestmentTransactionController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InvestmentHolding;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InvestmentTransactionController extends Controller
|
||||
{
|
||||
public function index(Request $request, string $holdingId)
|
||||
{
|
||||
$holding = InvestmentHolding::whereHas('account', function ($q) use ($request) {
|
||||
$q->where('user_id', $request->user()->id);
|
||||
})->findOrFail($holdingId);
|
||||
|
||||
return response()->json($holding->transactions()->orderBy('traded_at', 'desc')->get());
|
||||
}
|
||||
|
||||
public function store(Request $request, string $holdingId)
|
||||
{
|
||||
$holding = InvestmentHolding::whereHas('account', function ($q) use ($request) {
|
||||
$q->where('user_id', $request->user()->id);
|
||||
})->findOrFail($holdingId);
|
||||
|
||||
$validated = $request->validate([
|
||||
'action' => 'required|in:buy,sell',
|
||||
'shares' => 'nullable|numeric|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'fee' => 'nullable|numeric|min:0',
|
||||
'tax' => 'nullable|numeric|min:0',
|
||||
'traded_at' => 'required|date',
|
||||
'note' => 'nullable|string',
|
||||
'amount' => 'nullable|numeric|min:0', // 實際投入金額(基金用)
|
||||
'nav' => 'nullable|numeric|min:0', // 淨值(基金用)
|
||||
]);
|
||||
|
||||
$transaction = $holding->transactions()->create($validated);
|
||||
|
||||
// 更新持倉的 shares 和 avg_cost
|
||||
$this->recalculateHolding($holding);
|
||||
|
||||
return response()->json($transaction, 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $holdingId, string $id)
|
||||
{
|
||||
$holding = InvestmentHolding::whereHas('account', function ($q) use ($request) {
|
||||
$q->where('user_id', $request->user()->id);
|
||||
})->findOrFail($holdingId);
|
||||
|
||||
$transaction = $holding->transactions()->findOrFail($id);
|
||||
$transaction->delete();
|
||||
|
||||
$this->recalculateHolding($holding);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
// 每次買賣後重新計算平均成本和持有股數
|
||||
private function recalculateHolding(InvestmentHolding $holding): void
|
||||
{
|
||||
$transactions = $holding->transactions()->orderBy('traded_at')->get();
|
||||
|
||||
$totalUnits = 0;
|
||||
$totalCost = 0;
|
||||
$presentUnits = 0;
|
||||
|
||||
|
||||
foreach ($transactions as $tx) {
|
||||
if ($tx->action === 'buy') {
|
||||
if ($holding->type === 'fund') {
|
||||
$units = $tx->nav > 0 ? $tx->amount / $tx->nav : 0;
|
||||
$totalCost += $tx->amount + $tx->fee;
|
||||
$presentUnits += $units;
|
||||
$totalUnits += $units;
|
||||
} else {
|
||||
$totalCost += $tx->shares * $tx->price + $tx->fee;
|
||||
$presentUnits += $tx->shares;
|
||||
$totalUnits += $tx->shares;
|
||||
}
|
||||
} else {
|
||||
if ($holding->type === 'fund') {
|
||||
$units = $tx->nav > 0 ? $tx->amount / $tx->nav : 0;
|
||||
$totalUnits -= $units;
|
||||
} else {
|
||||
$totalUnits -= $tx->shares;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$holding->update([
|
||||
'shares' => max(0, $totalUnits), // 總持有股數
|
||||
'avg_cost' => $presentUnits > 0 ? $totalCost / $presentUnits : 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
292
app/Http/Controllers/Api/InvoiceImportController.php
Normal file
292
app/Http/Controllers/Api/InvoiceImportController.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Expense;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryRule;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvoiceImportController extends Controller
|
||||
{
|
||||
/**
|
||||
* 1. API 主入口:執行發票/帳本 CSV 上傳匯入
|
||||
* POST /api/expenses/import
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
$encoding = mb_detect_encoding($content, ['UTF-8', 'BIG-5', 'UTF-16LE', 'CP950'], true);
|
||||
|
||||
if ($encoding && $encoding !== 'UTF-8') {
|
||||
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
|
||||
}
|
||||
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content);
|
||||
|
||||
$rules = CategoryRule::with('category')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
|
||||
// 智慧判定財政部官方載具格式
|
||||
if (str_contains($content, '表頭=M') || str_contains($content, 'M|')) {
|
||||
[$imported, $updated] = $this->importPipeInvoice($content, $rules);
|
||||
$formatName = '財政部官方載具發票';
|
||||
} else {
|
||||
$handle = fopen('php://memory', 'r+');
|
||||
fwrite($handle, $content);
|
||||
rewind($handle);
|
||||
|
||||
$firstRow = fgetcsv($handle);
|
||||
$format = $this->detectFormat($firstRow);
|
||||
rewind($handle);
|
||||
|
||||
if ($format === 'andromoney') {
|
||||
[$imported, $updated] = $this->importAndroMoney($handle);
|
||||
$formatName = 'AndroMoney 帳本';
|
||||
} else {
|
||||
[$imported, $updated] = $this->importOldCommaInvoice($handle, $rules);
|
||||
$formatName = '傳統扁平發票 CSV';
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'imported' => $imported,
|
||||
'updated' => $updated,
|
||||
'message' => "【{$formatName}】匯入完成!成功新增 {$imported} 筆紀錄,自動跳過重複/負值金額共 {$updated} 筆。"
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 財政部官網 Pipe 格式解析
|
||||
*/
|
||||
private function importPipeInvoice(string $content, $rules): array
|
||||
{
|
||||
$imported = 0;
|
||||
$updated = 0;
|
||||
|
||||
$lines = preg_split('/\r\n|\r|\n/', $content);
|
||||
$invoices = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (empty($line) || str_contains($line, '表頭=')) continue;
|
||||
|
||||
$parts = explode('|', $line);
|
||||
$type = $parts[0] ?? '';
|
||||
|
||||
if ($type === 'M') {
|
||||
$invNum = trim($parts[6] ?? '');
|
||||
if (!$invNum) continue;
|
||||
|
||||
$invoices[$invNum] = [
|
||||
'date' => trim($parts[3] ?? ''),
|
||||
'seller' => trim($parts[5] ?? ''),
|
||||
'amount' => (float)trim($parts[7] ?? 0),
|
||||
'items' => []
|
||||
];
|
||||
} elseif ($type === 'D') {
|
||||
$invNum = trim($parts[1] ?? '');
|
||||
$itemName = trim($parts[3] ?? '');
|
||||
if ($invNum && isset($invoices[$invNum]) && $itemName) {
|
||||
$invoices[$invNum]['items'][] = $itemName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($invoices, $rules, &$imported, &$updated) {
|
||||
foreach ($invoices as $invoiceNumber => $info) {
|
||||
if ($info['amount'] < 0) {
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = Expense::where('user_id', auth()->id())
|
||||
->where('invoice_number', $invoiceNumber)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$d = $info['date'];
|
||||
$formattedDate = (strlen($d) === 8) ? substr($d, 0, 4) . '-' . substr($d, 4, 2) . '-' . substr($d, 6, 2) : now()->toDateString();
|
||||
|
||||
$itemName = implode(', ', $info['items']);
|
||||
if (mb_strlen($itemName) > 255) {
|
||||
$itemName = mb_substr($itemName, 0, 252) . '...';
|
||||
}
|
||||
|
||||
$categoryId = $this->guessCategory($info['seller'], $rules);
|
||||
|
||||
$expense = Expense::updateOrCreate(
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'invoice_number' => $invoiceNumber,
|
||||
],
|
||||
[
|
||||
'amount' => $info['amount'],
|
||||
'date' => $formattedDate,
|
||||
'seller_name' => $info['seller'],
|
||||
'item_name' => $itemName ?: $info['seller'],
|
||||
'note' => $info['seller'],
|
||||
'category_id' => $categoryId,
|
||||
]
|
||||
);
|
||||
|
||||
if ($expense->wasRecentlyCreated) {
|
||||
$imported++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [$imported, $updated];
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 舊版扁平逗號 CSV 匯入
|
||||
*/
|
||||
private function importOldCommaInvoice($handle, $rules): array
|
||||
{
|
||||
$imported = 0; $updated = 0;
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if (count($row) < 8) continue;
|
||||
|
||||
$invoiceNumber = trim($row[2] ?? '');
|
||||
$date = trim($row[1] ?? '');
|
||||
$amount = trim($row[3] ?? '0');
|
||||
$sellerName = trim($row[7] ?? '');
|
||||
$itemName = trim($row[13] ?? '');
|
||||
|
||||
if (empty($invoiceNumber)) continue;
|
||||
if ((float)$amount < 0) { $updated++; continue; }
|
||||
|
||||
$formattedDate = null;
|
||||
if (strlen($date) === 8) {
|
||||
$formattedDate = substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
|
||||
}
|
||||
|
||||
$exists = Expense::where('user_id', auth()->id())->where('invoice_number', $invoiceNumber)->exists();
|
||||
if ($exists) { $updated++; continue; }
|
||||
|
||||
$categoryId = $this->guessCategory($sellerName, $rules);
|
||||
|
||||
// 🟢 校正:移除所有舊外鍵欄位,對齊 Migration
|
||||
$expense = Expense::updateOrCreate(
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'invoice_number' => $invoiceNumber
|
||||
],
|
||||
[
|
||||
'amount' => (float)$amount,
|
||||
'date' => $formattedDate ?: now()->toDateString(),
|
||||
'seller_name' => $sellerName,
|
||||
'item_name' => $itemName,
|
||||
'note' => $sellerName,
|
||||
'category_id' => $categoryId,
|
||||
]
|
||||
);
|
||||
if ($expense->wasRecentlyCreated) { $imported++; } else { $updated++; }
|
||||
}
|
||||
return [$imported, $updated];
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. AndroMoney 格式匯入
|
||||
*/
|
||||
private function importAndroMoney($handle): array
|
||||
{
|
||||
$imported = 0; $updated = 0;
|
||||
fgetcsv($handle);
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
$row = array_map(fn($val) => mb_convert_encoding($val, 'UTF-8', 'Big5'), $row);
|
||||
if (count($row) < 6) continue;
|
||||
|
||||
$amount = trim($row[2] ?? '0');
|
||||
$catName = trim($row[3] ?? '');
|
||||
$subCatName = trim($row[4] ?? '');
|
||||
$date = trim($row[5] ?? '');
|
||||
$note = trim($row[8] ?? '');
|
||||
$sellerName = trim($row[13] ?? '');
|
||||
|
||||
if (preg_match('/^[0-9a-f]{20,}$/i', $note)) { $note = ''; }
|
||||
if (empty($amount) || empty($date) || (float)$amount == 0) continue;
|
||||
|
||||
$formattedDate = null;
|
||||
if (strlen($date) === 8 && is_numeric($date)) {
|
||||
$formattedDate = substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
|
||||
}
|
||||
if (!$formattedDate) { $updated++; continue; }
|
||||
|
||||
$categoryId = !empty($catName) ? $this->findOrCreateCategory($catName) : null;
|
||||
$subcategoryId = (!empty($subCatName) && $categoryId) ? $this->findOrCreateCategory($subCatName, $categoryId) : null;
|
||||
|
||||
// 🟢 校正:AndroMoney 沒有發票號碼,所以單純用使用者、日期、金額與備註作防重判斷
|
||||
$exists = Expense::where('user_id', auth()->id())
|
||||
->where('date', $formattedDate)
|
||||
->where('amount', abs((float)$amount))
|
||||
->where('note', $note ?: $sellerName)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Expense::create([
|
||||
'user_id' => auth()->id(),
|
||||
'amount' => abs((float)$amount),
|
||||
'date' => $formattedDate,
|
||||
'category_id' => $categoryId,
|
||||
'subcategory_id' => $subcategoryId,
|
||||
'note' => $note ?: $sellerName,
|
||||
'seller_name' => $sellerName,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
return [$imported, $updated];
|
||||
}
|
||||
|
||||
private function detectFormat(array $firstRow): string
|
||||
{
|
||||
if (isset($firstRow[1]) && str_contains($firstRow[1], 'AndroMoney')) { return 'andromoney'; }
|
||||
return 'invoice';
|
||||
}
|
||||
|
||||
private function findOrCreateCategory(string $name, ?int $parentId = null): int
|
||||
{
|
||||
$category = Category::firstOrCreate(
|
||||
['user_id' => auth()->id(), 'name' => $name, 'parent_id' => $parentId],
|
||||
['color' => $this->randomColor()]
|
||||
);
|
||||
return $category->id;
|
||||
}
|
||||
|
||||
private function randomColor(): string
|
||||
{
|
||||
$colors = ['#EF4444', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
|
||||
return $colors[array_rand($colors)];
|
||||
}
|
||||
|
||||
private function guessCategory($sellerName, $rules)
|
||||
{
|
||||
foreach ($rules as $rule) {
|
||||
if (str_contains($sellerName, $rule->keyword)) { return $rule->category_id; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Api/SecurityController.php
Normal file
48
app/Http/Controllers/Api/SecurityController.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Security;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SecurityController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'type' => ['required', 'in:stock,etf,fund'],
|
||||
'keyword' => ['required', 'string', 'min:1'],
|
||||
]);
|
||||
|
||||
$results = Security::query()
|
||||
->where('type', $validated['type'])
|
||||
->where(function ($query) use ($validated) {
|
||||
$query->where('code', 'ilike', "%{$validated['keyword']}%")
|
||||
->orWhere('name', 'ilike', "%{$validated['keyword']}%");
|
||||
})
|
||||
->orderBy('code')
|
||||
->limit(10)
|
||||
->get(['code', 'name']);
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
if ($validated['type'] === 'fund') {
|
||||
$res = Http::post("https://www.anuefund.com/anuefundApi/Search/Light", [
|
||||
'keyword' => $validated['keyword'],
|
||||
]);
|
||||
if ($res->ok()) {
|
||||
$data = $res->json();
|
||||
foreach ($data['data']['result'] as $item) {
|
||||
$results->push(new Security([
|
||||
'code' => $item['fundID'],
|
||||
'name' => $item['fundName'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
}
|
||||
269
app/Http/Controllers/Api/SecurityPriceController.php
Normal file
269
app/Http/Controllers/Api/SecurityPriceController.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/GoogleAuthController.php
Normal file
51
app/Http/Controllers/Auth/GoogleAuthController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class GoogleAuthController extends Controller
|
||||
{
|
||||
// 導向 Google 登入頁
|
||||
public function redirect()
|
||||
{
|
||||
return Socialite::driver('google')
|
||||
->scopes(['https://www.googleapis.com/auth/gmail.readonly'])
|
||||
->with([
|
||||
'access_type' => 'offline', // 💡 極其重要:強制要求 Google 回傳長期有效的 refresh_token
|
||||
'prompt' => 'consent' // 💡 強制跳出授權提示,確保每次都能順利拿到憑證
|
||||
])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
// Google 打回來後處理
|
||||
public function callback()
|
||||
{
|
||||
$googleUser = Socialite::driver('google')->stateless()->user();
|
||||
|
||||
$user = User::updateOrCreate(
|
||||
['email' => $googleUser->getEmail()],
|
||||
[
|
||||
'name' => $googleUser->getName(),
|
||||
'google_id' => $googleUser->getId(),
|
||||
'avatar' => $googleUser->getAvatar(),
|
||||
'password' => bcrypt(str()->random(24)),
|
||||
'google_access_token' => $googleUser->token,
|
||||
'google_refresh_token' => $googleUser->refreshToken, // 💡 只有第一次授權或加了 prompt=consent 才會給
|
||||
'google_token_expires_at' => now()->addSeconds($googleUser->expiresIn),
|
||||
]
|
||||
);
|
||||
|
||||
if ($googleUser->refreshToken) {
|
||||
$user->update(['google_refresh_token' => $googleUser->refreshToken]);
|
||||
}
|
||||
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
// 把 token 帶回前端
|
||||
return redirect("http://localhost:5173/#/auth/callback?token={$token}&name=" . urlencode($user->name));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CategoryRule;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
class CategoryRuleController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'keyword' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
]);
|
||||
|
||||
CategoryRule::create([
|
||||
...$validated,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function destroy(CategoryRule $categoryRule)
|
||||
{
|
||||
if ($categoryRule->user_id !== auth()->id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$categoryRule->delete();
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Expense;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$now = Carbon::now();
|
||||
|
||||
// 本月花費
|
||||
$monthlyTotal = Expense::where('user_id', $userId)
|
||||
->whereYear('date', $now->year)
|
||||
->whereMonth('date', $now->month)
|
||||
->sum('amount');
|
||||
|
||||
// 本月分類統計(圓餅圖)
|
||||
$categoryStats = Expense::with('category')
|
||||
->where('user_id', $userId)
|
||||
->whereYear('date', $now->year)
|
||||
->whereMonth('date', $now->month)
|
||||
->get()
|
||||
->groupBy(fn($e) => $e->category?->name ?? '未分類')
|
||||
->map(fn($group, $name) => [
|
||||
'name' => $name,
|
||||
'total' => $group->sum('amount'),
|
||||
'color' => $group->first()->category?->color ?? '#6B7280',
|
||||
])
|
||||
->values();
|
||||
|
||||
// 近6個月趨勢
|
||||
$monthlyTrend = collect(range(5, 0))->map(function ($i) use ($userId, $now) {
|
||||
$month = $now->copy()->subMonths($i);
|
||||
$total = Expense::where('user_id', $userId)
|
||||
->whereYear('date', $month->year)
|
||||
->whereMonth('date', $month->month)
|
||||
->sum('amount');
|
||||
|
||||
return [
|
||||
'month' => $month->format('Y/m'),
|
||||
'total' => (float) $total,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'monthlyTotal' => (float) $monthlyTotal,
|
||||
'categoryStats' => $categoryStats,
|
||||
'monthlyTrend' => $monthlyTrend,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryRule;
|
||||
use App\Models\Expense;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ExpenseController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$expenses = Expense::with('category')
|
||||
->where('user_id', auth()->id())
|
||||
->orderBy('date', 'desc')
|
||||
->get();
|
||||
|
||||
$categories = Category::where('user_id', auth()->id())->get();
|
||||
|
||||
$rules = CategoryRule::with('category')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
|
||||
return Inertia::render('Expenses/Index', [
|
||||
'expenses' => $expenses,
|
||||
'categories' => $categories,
|
||||
'rules' => $rules,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'note' => 'nullable|string|max:255',
|
||||
'date' => 'required|date',
|
||||
]);
|
||||
|
||||
Expense::create([
|
||||
...$validated,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Expense $expense)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Expense $expense)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Expense $expense)
|
||||
{
|
||||
$this->authorize('update', $expense);
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'note' => 'nullable|string|max:255',
|
||||
'date' => 'required|date',
|
||||
]);
|
||||
|
||||
$expense->update($validated);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Expense $expense)
|
||||
{
|
||||
$this->authorize('delete', $expense);
|
||||
$expense->delete();
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function destroyBatch(Request $request)
|
||||
{
|
||||
$request->validate(['ids' => 'required|array']);
|
||||
|
||||
Expense::whereIn('id', $request->ids)
|
||||
->where('user_id', auth()->id())
|
||||
->delete();
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryRule;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InvoiceImportController extends Controller
|
||||
{
|
||||
// ── 自動偵測格式 ──
|
||||
private function detectFormat(array $firstRow): string
|
||||
{
|
||||
// AndroMoney 第一行是 "Windows Excel", "理財幫手AndroMoney", 日期
|
||||
if (isset($firstRow[1]) && str_contains($firstRow[1], 'AndroMoney')) {
|
||||
return 'andromoney';
|
||||
}
|
||||
return 'invoice';
|
||||
}
|
||||
|
||||
// ── 取得或建立分類 ──
|
||||
private function findOrCreateCategory(string $name, ?int $parentId = null): int
|
||||
{
|
||||
$category = Category::firstOrCreate(
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'name' => $name,
|
||||
'parent_id' => $parentId,
|
||||
],
|
||||
[
|
||||
'color' => $this->randomColor(),
|
||||
]
|
||||
);
|
||||
return $category->id;
|
||||
}
|
||||
|
||||
private function randomColor(): string
|
||||
{
|
||||
$colors = [
|
||||
'#EF4444',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#3B82F6',
|
||||
'#8B5CF6',
|
||||
'#EC4899',
|
||||
'#14B8A6',
|
||||
'#F97316',
|
||||
];
|
||||
return $colors[array_rand($colors)];
|
||||
}
|
||||
|
||||
// ── 發票格式匯入 ──
|
||||
private function importInvoice($handle, $rules): array
|
||||
{
|
||||
$imported = 0;
|
||||
$updated = 0;
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if (count($row) < 8) continue;
|
||||
|
||||
$invoiceNumber = trim($row[2] ?? '');
|
||||
$date = trim($row[1] ?? '');
|
||||
$amount = trim($row[3] ?? '0');
|
||||
$sellerName = trim($row[7] ?? '');
|
||||
$itemName = trim($row[13] ?? '');
|
||||
|
||||
if (empty($invoiceNumber)) continue;
|
||||
if ((float)$amount < 0) {
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$formattedDate = null;
|
||||
if (strlen($date) === 8) {
|
||||
$formattedDate = substr($date, 0, 4) . '-'
|
||||
. substr($date, 4, 2) . '-'
|
||||
. substr($date, 6, 2);
|
||||
}
|
||||
|
||||
$exists = Expense::where('user_id', auth()->id())
|
||||
->where('invoice_number', $invoiceNumber)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryId = $this->guessCategory($sellerName, $rules);
|
||||
|
||||
$expense = Expense::updateOrCreate(
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'external_id' => $invoiceNumber,
|
||||
],
|
||||
[
|
||||
'type' => 'expense',
|
||||
'amount' => (float)$amount,
|
||||
'date' => $formattedDate,
|
||||
'seller_name' => $sellerName,
|
||||
'item_name' => $itemName,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'note' => $sellerName,
|
||||
'category_id' => $categoryId,
|
||||
]
|
||||
);
|
||||
if ($expense->wasRecentlyCreated) {
|
||||
$imported++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
return [$imported, $updated];
|
||||
}
|
||||
|
||||
// ── AndroMoney 格式匯入 ──
|
||||
private function importAndroMoney($handle): array
|
||||
{
|
||||
$imported = 0;
|
||||
$updated = 0;
|
||||
|
||||
// 跳過標題列(第二行)
|
||||
fgetcsv($handle);
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
// 轉換 Big5 → UTF-8
|
||||
$row = array_map(fn($val) => mb_convert_encoding($val, 'UTF-8', 'Big5'), $row);
|
||||
|
||||
if (count($row) < 6) continue;
|
||||
// 欄位對應
|
||||
// A=0:id, B=1:幣別, C=2:金額, D=3:分類, E=4:子分類
|
||||
// F=5:日期, G=6:付款, H=7:收款, I=8:備註, M=12:商家, N=13:uid
|
||||
$amount = trim($row[2] ?? '0');
|
||||
$catName = trim($row[3] ?? '');
|
||||
$subCatName = trim($row[4] ?? '');
|
||||
$date = trim($row[5] ?? '');
|
||||
$payOut = trim($row[6] ?? '');
|
||||
$payIn = trim($row[7] ?? '');
|
||||
$note = trim($row[8] ?? '');
|
||||
$sellerName = trim($row[13] ?? '');
|
||||
|
||||
// 過濾掉看起來像 uid 的值(32碼以上的hex字串)
|
||||
if (preg_match('/^[0-9a-f]{20,}$/i', $note)) {
|
||||
$note = '';
|
||||
}
|
||||
|
||||
if (empty($amount) || empty($date)) continue;
|
||||
if ((float)$amount == 0) continue;
|
||||
|
||||
// 日期格式轉換 20260106 → 2026-01-06
|
||||
$formattedDate = null;
|
||||
if (strlen($date) === 8 && is_numeric($date)) {
|
||||
$formattedDate = substr($date, 0, 4) . '-'
|
||||
. substr($date, 4, 2) . '-'
|
||||
. substr($date, 6, 2);
|
||||
}
|
||||
|
||||
// 日期無效就跳過
|
||||
if (!$formattedDate) {
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 判斷收入/支出
|
||||
// 付款欄有值 = 支出,收款欄有值 = 收入
|
||||
$type = !empty($payIn) ? 'income' : 'expense';
|
||||
|
||||
// 取得或建立主分類
|
||||
$categoryId = null;
|
||||
if (!empty($catName)) {
|
||||
$categoryId = $this->findOrCreateCategory($catName);
|
||||
}
|
||||
|
||||
// 取得或建立子分類(掛在主分類下)
|
||||
$subcategoryId = null;
|
||||
if (!empty($subCatName) && $categoryId) {
|
||||
$subcategoryId = $this->findOrCreateCategory($subCatName, $categoryId);
|
||||
}
|
||||
|
||||
// uid 是 row[12]
|
||||
$uid = trim($row[12] ?? '');
|
||||
|
||||
$expense = Expense::updateOrCreate(
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'external_id' => $uid ?: null,
|
||||
],
|
||||
[
|
||||
'type' => $type,
|
||||
'amount' => abs((float)$amount),
|
||||
'date' => $formattedDate,
|
||||
'category_id' => $categoryId,
|
||||
'subcategory_id' => $subcategoryId,
|
||||
'note' => $note ?: $sellerName,
|
||||
'seller_name' => $sellerName,
|
||||
]
|
||||
);
|
||||
|
||||
if ($expense->wasRecentlyCreated) {
|
||||
$imported++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
return [$imported, $updated];
|
||||
}
|
||||
|
||||
// ── 自動分類(發票用)──
|
||||
private function guessCategory($sellerName, $rules)
|
||||
{
|
||||
foreach ($rules as $rule) {
|
||||
if (str_contains($sellerName, $rule->keyword)) {
|
||||
return $rule->category_id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return inertia('Expenses/Import');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:csv,txt',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$handle = fopen($file->getPathname(), 'r');
|
||||
|
||||
// 讀第一行偵測格式
|
||||
$firstRow = fgetcsv($handle);
|
||||
$format = $this->detectFormat($firstRow);
|
||||
|
||||
$rules = CategoryRule::with('category')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
|
||||
if ($format === 'andromoney') {
|
||||
[$imported, $updated] = $this->importAndroMoney($handle);
|
||||
} else {
|
||||
[$imported, $updated] = $this->importInvoice($handle, $rules);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
$formatName = $format === 'andromoney' ? 'AndroMoney' : '發票';
|
||||
|
||||
return redirect()->route('expenses.index')->with([
|
||||
'message' => "【{$formatName}】匯入完成!新增 {$imported} 筆,更新 {$updated} 筆",
|
||||
]);
|
||||
}
|
||||
}
|
||||
140
app/Jobs/FetchAndParseGmailInvoices.php
Normal file
140
app/Jobs/FetchAndParseGmailInvoices.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\User;
|
||||
use App\Models\Expense;
|
||||
use App\Models\CategoryRule; // 💡 假設這是你存自動分類規則的 Model
|
||||
use App\Services\GmailParserService;
|
||||
use Google\Client as GoogleClient;
|
||||
use Google\Service\Gmail;
|
||||
use Google\Service\Gmail\ModifyMessageRequest;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FetchAndParseGmailInvoices implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $user;
|
||||
|
||||
/**
|
||||
* 這裡我們可以在初始化時傳入指定 User,或者在 handle 裡跑全部 User
|
||||
*/
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function handle(GmailParserService $parserService)
|
||||
{
|
||||
// 1. 檢查使用者憑證
|
||||
if (!$this->user->google_refresh_token) {
|
||||
Log::warning("User ID {$this->user->id} 試圖執行抓信任務,但缺乏 refresh_token。");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 初始化 Google Client 並刷新 Token
|
||||
$client = new GoogleClient();
|
||||
$client->setClientId(config('services.google.client_id'));
|
||||
$client->setClientSecret(config('services.google.client_secret'));
|
||||
$client->refreshToken($this->user->google_refresh_token);
|
||||
|
||||
$gmailService = new Gmail($client);
|
||||
|
||||
// 3. 搜尋帶有專屬標籤且「未讀」的信件
|
||||
$searchQuery = 'label:FinBuddy-Card is:unread';
|
||||
$response = $gmailService->users_messages->listUsersMessages('me', [
|
||||
'q' => $searchQuery,
|
||||
'maxResults' => 20 // 每次最多處理 20 筆,防爆
|
||||
]);
|
||||
|
||||
$messages = $response->getMessages();
|
||||
|
||||
if (empty($messages)) {
|
||||
Log::info("User ID {$this->user->id}: 沒發現符合條件的未讀刷卡信件。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 逐封拆解信件
|
||||
foreach ($messages as $msgItem) {
|
||||
$msgId = $msgItem->getId();
|
||||
$message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']);
|
||||
$payload = $message->getPayload();
|
||||
|
||||
// 抓取主旨
|
||||
$subject = '';
|
||||
foreach ($payload->getHeaders() as $header) {
|
||||
if ($header->getName() === 'Subject') {
|
||||
$subject = $header->getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 遞迴拿到內文
|
||||
$body = $this->getDecodedBody($payload);
|
||||
|
||||
// 5. 丟給 Parser 進行 Regex 解析
|
||||
$parsedData = $parserService->parse($subject, $body);
|
||||
|
||||
if ($parsedData) {
|
||||
// 🟢 智慧分類:根據商家名稱反查資料庫裡的自動分類規則
|
||||
// 假設你的規則表有關鍵字比對 (例如: keyword = 'PCHOME', category_id = 3)
|
||||
$matchedRule = CategoryRule::where('user_id', $this->user->id)
|
||||
->get()
|
||||
->first(function ($rule) use ($parsedData) {
|
||||
return Str::contains(strtoupper($parsedData['seller_name']), strtoupper($rule->keyword));
|
||||
});
|
||||
|
||||
$categoryId = $matchedRule ? $matchedRule->category_id : null;
|
||||
|
||||
// 6. 寫入消費紀錄(我們已經補上了 $fillable,絕對暢行無阻!)
|
||||
Expense::create([
|
||||
'user_id' => $this->user->id,
|
||||
'amount' => $parsedData['amount'],
|
||||
'item_name' => $parsedData['item_name'],
|
||||
'seller_name' => $parsedData['seller_name'],
|
||||
'date' => $parsedData['date'],
|
||||
'category_id' => $categoryId,
|
||||
'note' => 'Gmail背景自動記帳',
|
||||
]);
|
||||
|
||||
Log::info("User ID {$this->user->id}: 成功自動記帳一筆金額 {$parsedData['amount']},商家: {$parsedData['seller_name']}");
|
||||
}
|
||||
|
||||
// 7. 🟢 防重複防線:記帳成功後,立刻把信件在 Gmail 標記為「已讀」
|
||||
$mods = new ModifyMessageRequest();
|
||||
$mods->setRemoveLabelIds(['UNREAD']); // 移除未讀標籤 = 標記已讀
|
||||
$gmailService->users_messages->modify('me', $msgId, $mods);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("User ID {$this->user->id} Gmail 自動抓帳 Job 發生崩潰: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遞迴解析內文的輔助方法
|
||||
*/
|
||||
private function getDecodedBody($part)
|
||||
{
|
||||
$body = $part->getBody();
|
||||
if ($body && $body->getData()) {
|
||||
$sanitizedData = str_replace(['-', '_'], ['+', '/'], $body->getData());
|
||||
return base64_decode($sanitizedData);
|
||||
}
|
||||
$parts = $part->getParts();
|
||||
if ($parts) {
|
||||
foreach ($parts as $subPart) {
|
||||
$result = $this->getDecodedBody($subPart);
|
||||
if ($result) return $result;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
148
app/Jobs/UpdateAssetPriceJob.php
Normal file
148
app/Jobs/UpdateAssetPriceJob.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Security;
|
||||
use App\Models\SecurityPriceHistories;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdateAssetPriceJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $symbol;
|
||||
protected $type;
|
||||
protected $defaultName;
|
||||
|
||||
public function __construct(string $symbol, string $type, string $defaultName)
|
||||
{
|
||||
$this->symbol = $symbol;
|
||||
$this->type = $type;
|
||||
$this->defaultName = $defaultName;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
Log::info("🔄 開始從持倉清單發動爬蟲: [{$this->symbol}] {$this->defaultName}");
|
||||
|
||||
$isRealPrice = true;
|
||||
$latestPrice = 0;
|
||||
$latestPriceDate = null;
|
||||
$name = $this->defaultName;
|
||||
$changeAmount = 0;
|
||||
$changeRate = 0;
|
||||
$error = null;
|
||||
|
||||
// 股票去爬證交所
|
||||
if ($this->type === 'stock') {
|
||||
Log::info("開始抓取{$this->type}:{$this->symbol}價格");
|
||||
$response = Http::timeout(10)->get("https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=tse_{$this->symbol}.tw");
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$info = $json['msgArray'][0] ?? null;
|
||||
if (isset($info['^']) && !Carbon::parse($info['^'])->isToday()) {
|
||||
$isRealPrice = false;
|
||||
}
|
||||
|
||||
if ($info) {
|
||||
$latestPrice = floatval($info['z'] ?? $info['o'] ?? 0);
|
||||
$latestPriceDate = isset($info['d']) ? Carbon::parse($info['d'])->format('Y-m-d') : now()->format('Y-m-d');
|
||||
$name = $info['n'] ?? $this->defaultName;
|
||||
|
||||
if (isset($info['y']) && $latestPrice > 0) {
|
||||
$changeAmount = floatval($latestPrice - $info['y']);
|
||||
$changeRate = ($changeAmount / $info['y']) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($this->type === 'fund') {
|
||||
Log::info("開始抓取{$this->type}:{$this->symbol}價格");
|
||||
// 去鉅亨網抓
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders([
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer' => 'https://www.anuefund.com/',
|
||||
'Accept' => 'application/json, text/plain, */*',
|
||||
])
|
||||
->get("https://www.anuefund.com/anuefundApi/FundDetail/FundInfo", [
|
||||
'fundDetailEnum' => 'FundINFO',
|
||||
'FundID' => $this->symbol
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$headerInfo = $json['data']['hearder'] ?? null;
|
||||
|
||||
if (isset($headerInfo['navDate']) && !Carbon::parse($headerInfo['navDate'])->isToday()) {
|
||||
//不是當天資料不更新
|
||||
$isRealPrice = false;
|
||||
}
|
||||
|
||||
if ($headerInfo) {
|
||||
$latestPriceDate = isset($headerInfo['navDate']) ? substr($headerInfo['navDate'], 0, 10) : now()->format('Y-m-d');
|
||||
$latestPrice = floatval($headerInfo['nav'] ?? 0);
|
||||
|
||||
if ($latestPriceDate !== now()->format('Y-m-d')) {
|
||||
//不是當天資料不更新
|
||||
$isRealPrice = false;
|
||||
$latestPrice = 0;
|
||||
}
|
||||
|
||||
$name = $headerInfo['fundName'] ?? $this->defaultName;
|
||||
$changeAmount = floatval($headerInfo['upDown'] ?? 0);
|
||||
$changeRate = floatval($headerInfo['upDownRate'] ?? 0);
|
||||
}
|
||||
}
|
||||
Log::info("抓取{$this->type}:{$this->symbol}資料結束");
|
||||
}
|
||||
|
||||
// 爬蟲成功拿到有效股價,幫新表補資料
|
||||
if ($latestPrice > 0 && $isRealPrice) {
|
||||
Log::info("開始寫入Security和SecurityPriceHistories表,標的: [{$this->symbol}],價格: {$latestPrice}---");
|
||||
$security = Security::updateOrCreate(
|
||||
['code' => $this->symbol],
|
||||
[
|
||||
'type' => $this->type,
|
||||
'name' => $name,
|
||||
'latest_price' => $latestPrice,
|
||||
'latest_price_at' => $latestPriceDate,
|
||||
'change_amount' => $changeAmount,
|
||||
'change_rate' => $changeRate,
|
||||
'source' => 'twse',
|
||||
]
|
||||
);
|
||||
|
||||
SecurityPriceHistories::updateOrCreate(
|
||||
[
|
||||
'security_id' => $security->id,
|
||||
'price_date' => now()->format('Y-m-d'), // 記錄今天的日期
|
||||
],
|
||||
[
|
||||
'price' => $latestPrice,
|
||||
'change_amount' => $changeAmount,
|
||||
'change_rate' => $changeRate,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
Log::info("✅ Security & SecurityPriceHistories 標的 [{$this->symbol}] 補齊並更新成功: {$latestPrice}");
|
||||
} elseif (!$isRealPrice) {
|
||||
Log::info("ℹ️ 標的 [{$this->symbol}] 抓到的價格是非即時的,不更新價格資訊。");
|
||||
} else {
|
||||
Log::warning("⚠️ 標的 [{$this->symbol}] 未能從證交所抓到有效價格,{$error}");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("❌ 更新標的 [{$this->symbol}] 價格時發生異常: " . $e->getMessage());
|
||||
$this->release(60);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/Models/Account.php
Normal file
54
app/Models/Account.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'type',
|
||||
'currency',
|
||||
'balance',
|
||||
'note',
|
||||
'billing_day',
|
||||
'payment_day',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function holdings()
|
||||
{
|
||||
return $this->hasMany(InvestmentHolding::class);
|
||||
}
|
||||
|
||||
public function expenses()
|
||||
{
|
||||
return $this->hasMany(Expense::class);
|
||||
}
|
||||
|
||||
public function unpaidAmount()
|
||||
{
|
||||
$billingDay = $this->billing_day ?? 1;
|
||||
|
||||
$now = now();
|
||||
|
||||
// 如果今天還沒到結帳日,帳單週期是上個月結帳日到這個月結帳日
|
||||
if ($now->day < $billingDay) {
|
||||
$start = $now->copy()->subMonth()->setDay($billingDay);
|
||||
$end = $now->copy()->setDay($billingDay)->subDay();
|
||||
} else {
|
||||
$start = $now->copy()->setDay($billingDay);
|
||||
$end = $now->copy()->addMonth()->setDay($billingDay)->subDay();
|
||||
}
|
||||
|
||||
return $this->expenses()
|
||||
->whereBetween('date', [$start->toDateString(), $end->toDateString()])
|
||||
->sum('amount');
|
||||
}
|
||||
}
|
||||
10
app/Models/AccountRule.php
Normal file
10
app/Models/AccountRule.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AccountRule extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -7,18 +7,18 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Expense extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'category_id',
|
||||
'subcategory_id',
|
||||
'type',
|
||||
'amount',
|
||||
'note',
|
||||
'date',
|
||||
'invoice_number',
|
||||
'seller_name',
|
||||
'item_name',
|
||||
'external_id',
|
||||
];
|
||||
'user_id',
|
||||
'amount',
|
||||
'category_id',
|
||||
'item_name',
|
||||
'date',
|
||||
'note',
|
||||
'seller_name',
|
||||
'account_id',
|
||||
'is_amortized',
|
||||
'period_start',
|
||||
'period_end',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date:Y-m-d',
|
||||
@@ -39,4 +39,9 @@ class Expense extends Model
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'subcategory_id');
|
||||
}
|
||||
|
||||
public function account()
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Models/InvestmentHolding.php
Normal file
32
app/Models/InvestmentHolding.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InvestmentHolding extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'type',
|
||||
'symbol',
|
||||
'name',
|
||||
'shares',
|
||||
'avg_cost',
|
||||
];
|
||||
|
||||
public function account()
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function transactions()
|
||||
{
|
||||
return $this->hasMany(InvestmentTransaction::class, 'holding_id');
|
||||
}
|
||||
|
||||
public function security()
|
||||
{
|
||||
return $this->hasOne(Security::class, 'code', 'symbol');
|
||||
}
|
||||
}
|
||||
30
app/Models/InvestmentTransaction.php
Normal file
30
app/Models/InvestmentTransaction.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InvestmentTransaction extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'holding_id',
|
||||
'action',
|
||||
'shares',
|
||||
'price',
|
||||
'fee',
|
||||
'tax',
|
||||
'traded_at',
|
||||
'note',
|
||||
'amount', // 實際投入金額(基金用)
|
||||
'nav', // 淨值(基金用)
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'traded_at' => 'date',
|
||||
];
|
||||
|
||||
public function holding()
|
||||
{
|
||||
return $this->belongsTo(InvestmentHolding::class, 'holding_id');
|
||||
}
|
||||
}
|
||||
33
app/Models/Security.php
Normal file
33
app/Models/Security.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Security extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'type',
|
||||
'source',
|
||||
'name',
|
||||
'latest_price',
|
||||
'latest_price_at',
|
||||
'change_amount',
|
||||
'change_rate',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'latest_price_at' => 'datetime',
|
||||
'latest_price' => 'decimal:4',
|
||||
'change_amount' => 'decimal:4',
|
||||
'change_rate' => 'decimal:4',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function historicPrices()
|
||||
{
|
||||
return $this->hasMany(SecurityPriceHistories::class, 'security_id', 'id');
|
||||
}
|
||||
}
|
||||
29
app/Models/SecurityPriceHistories.php
Normal file
29
app/Models/SecurityPriceHistories.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SecurityPriceHistories extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'security_id',
|
||||
'price_date',
|
||||
'price',
|
||||
'change_amount',
|
||||
'change_rate',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:4',
|
||||
'price_date' => 'datetime',
|
||||
'change_amount' => 'decimal:4',
|
||||
'change_rate' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function security()
|
||||
{
|
||||
return $this->belongsTo(Security::class, 'security_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ namespace App\Models;
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory, Notifiable, HasApiTokens;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -21,6 +23,11 @@ class User extends Authenticatable
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'google_id',
|
||||
'avatar',
|
||||
'google_access_token',
|
||||
'google_refresh_token',
|
||||
'google_token_expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -45,4 +52,9 @@ class User extends Authenticatable
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function accounts()
|
||||
{
|
||||
return $this->hasMany(Account::class);
|
||||
}
|
||||
}
|
||||
|
||||
180
app/Services/GmailParserService.php
Normal file
180
app/Services/GmailParserService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class GmailParserService
|
||||
{
|
||||
|
||||
protected array $parserMatrix = [
|
||||
[
|
||||
'keywords' => ['國泰世華銀行消費彙整通知'],
|
||||
'method' => 'parseCathaySummary'
|
||||
],
|
||||
[
|
||||
'keywords' => ['永豐銀行信用卡消費通知'],
|
||||
'method' => 'parseSinoPacNotification'
|
||||
],
|
||||
[
|
||||
'keywords' => ['電子發票', '載具', '發票開立通知'],
|
||||
'method' => 'parseElectronicInvoice'
|
||||
],
|
||||
[
|
||||
'keywords' => ['消費通知', '刷卡', '卡片消費'],
|
||||
'method' => 'parseCreditCardNotification'
|
||||
],
|
||||
];
|
||||
|
||||
public function parse(string $subject, string $body): ?array
|
||||
{
|
||||
// 🔄 遍歷矩陣,尋找第一個命中的規則
|
||||
foreach ($this->parserMatrix as $rule) {
|
||||
if (Str::contains($subject, $rule['keywords'])) {
|
||||
$method = $rule['method'];
|
||||
|
||||
// 動態呼叫對應的方法 (例如 $this->parseCathaySummary($body) )
|
||||
return $this->{$method}($body);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("【Parser】忽略信件,主旨未命中任何對照規則: [{$subject}]");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🟢 國泰世華消費彙整信件專屬解析
|
||||
*/
|
||||
private function parseCathaySummary(string $body): ?array
|
||||
{
|
||||
$cleanBody = preg_replace('/\s+/', ' ', $body);
|
||||
|
||||
if (preg_match('/(\d{4}\/\d{2}\/\d{2})/u', $cleanBody, $dateMatches)) {
|
||||
$date = Carbon::createFromFormat('Y/m/d', $dateMatches[1])->format('Y-m-d');
|
||||
} else {
|
||||
$date = now()->format('Y-m-d');
|
||||
}
|
||||
|
||||
$pattern = '/NT\$\s*([0-9,]+)\s*<\/td>\s*<td[^>]*>\s*([^<\s&]+)\s*<\/td>/u';
|
||||
|
||||
if (!preg_match_all($pattern, $cleanBody, $matches, PREG_SET_ORDER)) {
|
||||
Log::error("【Parser】國泰信件未命中消費明細。");
|
||||
return null;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($matches as $match) {
|
||||
$sellerName = $this->convertFullWidthToHalfWidth(trim($match[2]));
|
||||
$results[] = [
|
||||
'amount' => floatval(str_replace(',', '', $match[1])),
|
||||
'item_name' => "信用卡消費: {$sellerName}",
|
||||
'seller_name' => $sellerName,
|
||||
'date' => $date,
|
||||
'category_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 永豐銀行信用卡消費通知(單筆制)
|
||||
*/
|
||||
private function parseSinoPacNotification(string $body): ?array
|
||||
{
|
||||
$cleanBody = preg_replace('/\s+/', ' ', $body);
|
||||
$amount = 0;
|
||||
$sellerName = '永豐刷卡消費';
|
||||
|
||||
if (preg_match('/(?:新臺幣|TWD)\s*([0-9,]+)/u', $cleanBody, $amountMatches)) {
|
||||
$amount = floatval(str_replace(',', '', $amountMatches[1]));
|
||||
}
|
||||
|
||||
if (preg_match('/特約商店[::]\s*([^<\s]+)/u', $cleanBody, $merchantMatches)) {
|
||||
$sellerName = trim($merchantMatches[1]);
|
||||
}
|
||||
|
||||
if ($amount === 0) return null;
|
||||
|
||||
if (preg_match('/(\d{4}\/\d{2}\/\d{2})/u', $cleanBody, $dateMatches)) {
|
||||
$date = Carbon::createFromFormat('Y/m/d', $dateMatches[1])->format('Y-m-d');
|
||||
} else {
|
||||
$date = now()->format('Y-m-d');
|
||||
}
|
||||
|
||||
$sellerName = $this->convertFullWidthToHalfWidth($sellerName);
|
||||
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'item_name' => "信用卡消費: {$sellerName}",
|
||||
'seller_name' => $sellerName,
|
||||
'date' => $date,
|
||||
'category_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析電子發票信件
|
||||
*/
|
||||
private function parseElectronicInvoice(string $body): ?array
|
||||
{
|
||||
$amount = 0;
|
||||
$sellerName = '未知商家';
|
||||
|
||||
if (preg_match('/(總計|金額|總金額)[::]\s*?\$?([0-9,]+)/u', $body, $matches)) {
|
||||
$amount = floatval(str_replace(',', '', $matches[2]));
|
||||
}
|
||||
if (preg_match('/(賣方|開立店家|商店名稱)[::]\s*?([^\n<\s]+)/u', $body, $matches)) {
|
||||
$sellerName = trim($matches[2]);
|
||||
}
|
||||
|
||||
if ($amount === 0) return null;
|
||||
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'item_name' => '電子發票消費',
|
||||
'seller_name' => $sellerName,
|
||||
'date' => now()->format('Y-m-d'),
|
||||
'category_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析信用卡消費通知(泛用型備援)
|
||||
*/
|
||||
private function parseCreditCardNotification(string $body): ?array
|
||||
{
|
||||
$amount = 0;
|
||||
$sellerName = '一般刷卡消費';
|
||||
|
||||
if (preg_match('/(NT\$|元|TWD)\s*?([0-9,]+)/u', $body, $matches)) {
|
||||
$amount = floatval(str_replace(',', '', $matches[2]));
|
||||
} elseif (preg_match('/([0-9,]+)\s*?(元)/u', $body, $matches)) {
|
||||
$amount = floatval(str_replace(',', '', $matches[1]));
|
||||
}
|
||||
|
||||
if (preg_match('/於\s*?([^\n\]((]+?)\s*?消費/u', $body, $matches)) {
|
||||
$sellerName = trim($matches[1]);
|
||||
} elseif (preg_match('/(特約商店|消費商店|商店)[::]\s*?([^\n<\s]+)/u', $body, $matches)) {
|
||||
$sellerName = trim($matches[2]);
|
||||
}
|
||||
|
||||
if ($amount === 0) return null;
|
||||
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'item_name' => "刷卡消費: {$sellerName}",
|
||||
'seller_name' => $sellerName,
|
||||
'date' => now()->format('Y-m-d'),
|
||||
'category_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function convertFullWidthToHalfWidth(string $str): string
|
||||
{
|
||||
$str = str_replace(' ', '', $str);
|
||||
return mb_convert_kana($str, 'asKV', 'UTF-8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user