'Import paragonów', 'receipts' => $this->receipts()->withCounts(), ]); } public function new() { return view('receipts/new', ['title' => 'Nowy import paragonu']); } /** * Upload + parsowanie AI + zapis pozycji z podpowiedziami kategorii. */ public function create() { // Walidacja bez regul zaleznych od finfo (ext_in/mime_in zgaduja typ przez fileinfo). $rules = [ 'receipt' => 'uploaded[receipt]|max_size[receipt,10240]', ]; if (! $this->validate($rules)) { return redirect()->back()->with('error', implode(' ', $this->validator->getErrors())); } $file = $this->request->getFile('receipt'); if (! $file->isValid()) { return redirect()->back()->with('error', 'Nieprawidłowy plik.'); } // Whitelist rozszerzen na surowej nazwie klienta (bez finfo). $allowed = ['jpg', 'jpeg', 'png', 'pdf', 'json']; if (! in_array(strtolower($file->getClientExtension()), $allowed, true)) { return redirect()->back()->with('error', 'Dozwolone formaty: JPG, PNG, PDF, JSON.'); } $receiptId = null; $model = $this->receipts(); $duplicate = $model->hasOriginalName($file->getClientName()); try { // Upewnij sie, ze katalog docelowy istnieje i jest zapisywalny (hostido). $dir = WRITEPATH . 'uploads/receipts'; if (! is_dir($dir) && ! @mkdir($dir, 0775, true) && ! is_dir($dir)) { throw new \RuntimeException('Nie mozna utworzyc katalogu na skany: ' . $dir); } if (! is_writable($dir)) { throw new \RuntimeException('Katalog na skany nie jest zapisywalny: ' . $dir); } // Losowa nazwa z rozszerzenia klienckiego — bez getRandomName()/finfo (wylaczone na hostingu). $ext = strtolower($file->getClientExtension() ?: 'bin'); $newName = bin2hex(random_bytes(8)) . '.' . $ext; $storedRel = $file->store('receipts', $newName); // Typ MIME po rozszerzeniu (bez finfo). $mime = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'pdf' => 'application/pdf', 'json' => 'application/json'][$ext] ?? 'application/octet-stream'; $receiptId = $model->insert([ 'file_path' => $storedRel, 'original_name' => $file->getClientName(), 'mime' => $mime, 'status' => 'parsing', ], true); $categories = model(CategoryModel::class)->flatList('expense'); $names = array_column($categories, 'name'); $nameToId = []; foreach ($categories as $c) { $nameToId[mb_strtolower($c['name'])] = (int) $c['id']; } $parsed = (new ReceiptParser())->parse(WRITEPATH . 'uploads/' . $storedRel, $mime, $names); $mapModel = model(ReceiptCategoryMapModel::class); foreach ($parsed['items'] as $it) { $aiCatId = $nameToId[mb_strtolower((string) $it['category'])] ?? null; // Priorytet nauczonego mapowania nad propozycja AI (obie tylko sugeruja). $suggested = $mapModel->suggest($it['name']) ?? $aiCatId; $this->items()->insert([ 'receipt_id' => $receiptId, 'name' => $it['name'], 'display_name' => $it['display'], 'qty' => $it['qty'], 'amount' => $it['amount'], 'suggested_category_id' => $suggested, ]); } $model->update($receiptId, [ 'merchant' => $parsed['merchant'], 'receipt_date' => $parsed['date'], 'total' => $parsed['total'], 'raw_json' => json_encode($parsed, JSON_UNESCAPED_UNICODE), 'status' => 'parsed', ]); $redirect = redirect()->to('receipts/' . $receiptId . '/review'); return $duplicate ? $redirect->with('warning', 'Plik o nazwie „' . $file->getClientName() . '” był już wcześniej importowany.') : $redirect; } catch (Throwable $e) { log_message('error', 'Import paragonu: ' . $e->getMessage()); $msg = mb_substr($e->getMessage(), 0, 255); if ($receiptId !== null) { $model->update($receiptId, ['status' => 'failed', 'error_msg' => $msg]); return redirect()->to('receipts/' . $receiptId) ->with('error', 'Import nie powiódł się: ' . $e->getMessage()); } // Blad przed utworzeniem rekordu (np. zapis pliku) — zapisz slad do odczytu. $model->insert([ 'file_path' => '', 'original_name' => $file->getClientName(), 'mime' => $file->getClientMimeType() ?: 'application/octet-stream', 'status' => 'failed', 'error_msg' => $msg, ]); return redirect()->back()->with('error', 'Import nie powiódł się: ' . $e->getMessage()); } } /** * Ekran przegladu: pozycje z selectem kategorii, grupowane po kategorii w podgladzie. */ public function review($id) { $receipt = $this->receipts()->find((int) $id); if (! $receipt) { return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.'); } if ($receipt['status'] === 'imported') { return redirect()->to('receipts/' . $id)->with('error', 'Ten paragon został już zaksięgowany.'); } return view('receipts/review', [ 'title' => 'Przegląd paragonu', 'receipt' => $receipt, 'items' => $this->items()->forReceipt((int) $id), 'categories' => model(CategoryModel::class)->tree('expense'), ]); } /** * Zatwierdzenie: grupowanie po kategorii -> 1 operacja/kategoria (suma) + uczenie mapowan. */ public function confirm($id) { $receipt = $this->receipts()->find((int) $id); if (! $receipt) { return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.'); } if ($receipt['status'] === 'imported') { return redirect()->to('receipts/' . $id)->with('error', 'Ten paragon został już zaksięgowany.'); } $chosen = (array) $this->request->getPost('chosen_category_id'); // itemId => categoryId $postDisp = (array) $this->request->getPost('display'); // itemId => poprawiona ladna nazwa $postAmt = (array) $this->request->getPost('amount'); // itemId => poprawiona kwota $items = $this->items()->forReceipt((int) $id); $opModel = model(OperationModel::class); $mapModel = model(ReceiptCategoryMapModel::class); // Grupowanie: categoryId => ['sum' => float, 'names' => [...]] $groups = []; $db = db_connect(); $db->transStart(); foreach ($items as $it) { $catId = isset($chosen[$it['id']]) && $chosen[$it['id']] !== '' ? (int) $chosen[$it['id']] : null; // Ladna nazwa i kwota edytowalne; surowa 'name' (klucz mapowania) pozostaje z paragonu. $display = isset($postDisp[$it['id']]) ? trim((string) $postDisp[$it['id']]) : (string) $it['display_name']; $amount = isset($postAmt[$it['id']]) ? round((float) $postAmt[$it['id']], 2) : (float) $it['amount']; if ($display === '') { $display = $it['display_name'] ?: $it['name']; } $this->items()->update($it['id'], [ 'display_name' => $display, 'amount' => number_format($amount, 2, '.', ''), 'chosen_category_id' => $catId, ]); if ($catId === null) { continue; // pominieta pozycja — nie ksiegujemy } $groups[$catId]['sum'] = ($groups[$catId]['sum'] ?? 0) + $amount; $groups[$catId]['names'][] = $display; $mapModel->remember($it['name'], $catId); // uczenie na surowej nazwie z paragonu -> kategoria } $date = $receipt['receipt_date'] ?: date('Y-m-d'); $prefix = $receipt['merchant'] ? $receipt['merchant'] . ': ' : 'Paragon: '; $created = 0; foreach ($groups as $catId => $g) { $desc = $prefix . implode(', ', $g['names']); $opModel->insert([ 'date' => $date, 'amount' => number_format($g['sum'], 2, '.', ''), 'type' => 'expense', 'category_id' => $catId, 'receipt_id' => (int) $id, 'description' => mb_substr($desc, 0, 255), ]); $created++; } $this->receipts()->update($id, ['status' => 'imported']); $db->transComplete(); if ($db->transStatus() === false) { return redirect()->to('receipts/' . $id)->with('error', 'Nie udało się zaksięgować paragonu (transakcja).'); } return redirect()->to('receipts/' . $id) ->with('success', 'Zaksięgowano ' . $created . ' operacji z paragonu.'); } public function show($id) { $receipt = $this->receipts()->find((int) $id); if (! $receipt) { return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.'); } $operations = []; if ($receipt['status'] === 'imported') { $operations = db_connect()->table('operations o') ->select('o.*, c.name AS category_name') ->join('categories c', 'c.id = o.category_id', 'left') ->where('o.receipt_id', (int) $id) ->orderBy('o.id', 'ASC')->get()->getResultArray(); } return view('receipts/show', [ 'title' => 'Paragon', 'receipt' => $receipt, 'items' => $this->items()->forReceipt((int) $id), 'operations' => $operations, ]); } /** * Serwowanie skanu spoza public/ (tylko pod filtrem auth). */ public function file($id) { $receipt = $this->receipts()->find((int) $id); if (! $receipt) { return redirect()->to('receipts')->with('error', 'Nie znaleziono paragonu.'); } $path = WRITEPATH . 'uploads/' . $receipt['file_path']; if (! is_file($path)) { return redirect()->to('receipts/' . $id)->with('error', 'Plik skanu nie istnieje.'); } return $this->response->setHeader('Content-Type', $receipt['mime'])->setBody(file_get_contents($path)); } public function delete($id) { $receipt = $this->receipts()->find((int) $id); if ($receipt) { $path = WRITEPATH . 'uploads/' . $receipt['file_path']; if (is_file($path)) { @unlink($path); } $this->receipts()->delete((int) $id); // CASCADE czysci receipt_items } return redirect()->to('receipts')->with('success', 'Paragon usunięty.'); } }