buid new app

This commit is contained in:
2026-03-09 01:00:49 +08:00
commit 97aed742af
126 changed files with 19341 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
<?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}",
]);
}
}