update
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\RememberTokenModel;
|
||||
|
||||
/**
|
||||
* Logowanie jednego uzytkownika wprost z .env (user_email + user_password).
|
||||
* ponytail: jawne haslo z .env, narzedzie jednoosobowe. Podniesc do password_hash,
|
||||
@@ -28,7 +30,22 @@ class Auth extends BaseController
|
||||
'user_email' => $email,
|
||||
]);
|
||||
|
||||
return redirect()->to('/dashboard');
|
||||
$response = redirect()->to('/dashboard');
|
||||
if ($this->request->getPost('remember') === '1') {
|
||||
$response->setCookie(
|
||||
'remember_token',
|
||||
(new RememberTokenModel())->issueToken(),
|
||||
RememberTokenModel::LIFETIME_SECONDS,
|
||||
'',
|
||||
'/',
|
||||
'',
|
||||
ENVIRONMENT === 'production',
|
||||
true,
|
||||
'Lax'
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', 'Nieprawidłowy email lub hasło.');
|
||||
@@ -39,8 +56,13 @@ class Auth extends BaseController
|
||||
|
||||
public function logout()
|
||||
{
|
||||
$token = (string) $this->request->getCookie('remember_token');
|
||||
if ($token !== '') {
|
||||
(new RememberTokenModel())->deleteToken($token);
|
||||
}
|
||||
|
||||
session()->destroy();
|
||||
|
||||
return redirect()->to('/login');
|
||||
return redirect()->to('/login')->deleteCookie('remember_token', '', '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\ReceiptParser;
|
||||
use App\Models\CategoryModel;
|
||||
use App\Models\OperationModel;
|
||||
use App\Models\ReceiptCategoryMapModel;
|
||||
use App\Models\ReceiptItemModel;
|
||||
use App\Models\ReceiptModel;
|
||||
use Throwable;
|
||||
|
||||
class Receipts extends BaseController
|
||||
{
|
||||
private function receipts(): ReceiptModel
|
||||
{
|
||||
return model(ReceiptModel::class);
|
||||
}
|
||||
|
||||
private function items(): ReceiptItemModel
|
||||
{
|
||||
return model(ReceiptItemModel::class);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('receipts/index', [
|
||||
'title' => '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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user