Files
finance_app/app/Http/Controllers/Api/InvoiceImportController.php
henry yo 85972f950c feat: 前端分離+功能api化
1. 新增資產管理CRUD、googleOAuth登入
2. 更改原記帳controller
2026-06-25 14:19:07 +08:00

292 lines
10 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\Api;
use App\Http\Controllers\Controller;
use App\Models\Expense;
use App\Models\Category;
use App\Models\CategoryRule;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InvoiceImportController extends Controller
{
/**
* 1. API 主入口:執行發票/帳本 CSV 上傳匯入
* POST /api/expenses/import
*/
public function store(Request $request)
{
$request->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;
}
}