feat: 前端分離+功能api化

1. 新增資產管理CRUD、googleOAuth登入
2. 更改原記帳controller
This commit is contained in:
2026-06-25 14:19:07 +08:00
parent 7bb931c97b
commit 85972f950c
70 changed files with 4754 additions and 1520 deletions

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