Files
finance_app/app/Http/Controllers/InvoiceImportController.php
2026-03-09 01:00:49 +08:00

259 lines
7.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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}",
]);
}
}