mirror of
https://github.com/henry4682/finance_app.git
synced 2026-07-16 08:20:00 +00:00
292 lines
10 KiB
PHP
292 lines
10 KiB
PHP
<?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;
|
||
}
|
||
} |