mirror of
https://github.com/henry4682/finance_app.git
synced 2026-07-16 00:10:00 +00:00
259 lines
7.9 KiB
PHP
259 lines
7.9 KiB
PHP
<?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} 筆",
|
||
]);
|
||
}
|
||
}
|