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