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} 筆", ]); } }