update
This commit is contained in:
@@ -23,6 +23,16 @@ $routes->group('', ['filter' => 'auth'], static function (RouteCollection $route
|
||||
$routes->post('categories/(:num)', 'Categories::update/$1');
|
||||
$routes->get('categories/(:num)/delete', 'Categories::delete/$1');
|
||||
|
||||
// Import paragonów (AI parsowanie + uczone podpowiedzi kategorii)
|
||||
$routes->get('receipts', 'Receipts::index');
|
||||
$routes->get('receipts/new', 'Receipts::new');
|
||||
$routes->post('receipts', 'Receipts::create');
|
||||
$routes->get('receipts/(:num)/review', 'Receipts::review/$1');
|
||||
$routes->post('receipts/(:num)/confirm', 'Receipts::confirm/$1');
|
||||
$routes->get('receipts/(:num)/file', 'Receipts::file/$1');
|
||||
$routes->get('receipts/(:num)/delete', 'Receipts::delete/$1');
|
||||
$routes->get('receipts/(:num)', 'Receipts::show/$1');
|
||||
|
||||
// Operacje
|
||||
$routes->get('operations', 'Operations::index');
|
||||
$routes->get('operations/new', 'Operations::new');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateReceipts extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'file_path' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||
'original_name' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||
'mime' => ['type' => 'VARCHAR', 'constraint' => 100],
|
||||
'merchant' => ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true],
|
||||
'receipt_date' => ['type' => 'DATE', 'null' => true],
|
||||
'total' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => true],
|
||||
'status' => ['type' => 'ENUM', 'constraint' => ['parsing', 'parsed', 'failed', 'imported'], 'default' => 'parsing'],
|
||||
'raw_json' => ['type' => 'LONGTEXT', 'null' => true],
|
||||
'error_msg' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('status');
|
||||
$this->forge->createTable('receipts', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('receipts', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateReceiptItems extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'receipt_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||
'name' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||
'qty' => ['type' => 'DECIMAL', 'constraint' => '10,3', 'null' => true],
|
||||
'amount' => ['type' => 'DECIMAL', 'constraint' => '12,2'],
|
||||
'suggested_category_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'chosen_category_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('receipt_id');
|
||||
// Kasacja paragonu kasuje jego pozycje.
|
||||
$this->forge->addForeignKey('receipt_id', 'receipts', 'id', 'CASCADE', 'CASCADE');
|
||||
// Kasacja kategorii zeruje podpowiedz/wybor (nie blokuje).
|
||||
$this->forge->addForeignKey('suggested_category_id', 'categories', 'id', 'SET NULL', 'SET NULL');
|
||||
$this->forge->addForeignKey('chosen_category_id', 'categories', 'id', 'SET NULL', 'SET NULL');
|
||||
$this->forge->createTable('receipt_items', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('receipt_items', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateReceiptCategoryMap extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'item_name' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||
'category_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
// Znormalizowana nazwa pozycji jest kluczem uczenia (jedno mapowanie na nazwe).
|
||||
$this->forge->addUniqueKey('item_name');
|
||||
$this->forge->addForeignKey('category_id', 'categories', 'id', 'CASCADE', 'CASCADE');
|
||||
$this->forge->createTable('receipt_category_map', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('receipt_category_map', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddReceiptIdToOperations extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addColumn('operations', [
|
||||
'receipt_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'category_id'],
|
||||
]);
|
||||
// Kasacja paragonu nie kasuje operacji, tylko zrywa powiazanie.
|
||||
$this->forge->addForeignKey('receipt_id', 'receipts', 'id', 'SET NULL', 'SET NULL', 'operations_receipt_id_fk');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropForeignKey('operations', 'operations_receipt_id_fk');
|
||||
$this->forge->dropColumn('operations', 'receipt_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddDisplayNameToReceiptItems extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addColumn('receipt_items', [
|
||||
// Ladna, czytelna nazwa do wyswietlania i opisu operacji (name = surowa z paragonu, klucz mapowania).
|
||||
'display_name' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true, 'after' => 'name'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropColumn('receipt_items', 'display_name');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateRememberTokens extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'selector' => ['type' => 'VARCHAR', 'constraint' => 32],
|
||||
'validator_hash' => ['type' => 'CHAR', 'constraint' => 64],
|
||||
'expires_at' => ['type' => 'DATETIME'],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey('selector');
|
||||
$this->forge->addKey('expires_at');
|
||||
$this->forge->createTable('remember_tokens', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('remember_tokens', true);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Models\RememberTokenModel;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
@@ -13,9 +14,21 @@ class AuthFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
if (! session()->get('logged_in')) {
|
||||
return redirect()->to('/login');
|
||||
if (session()->get('logged_in')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = (string) $request->getCookie('remember_token');
|
||||
if ($token !== '' && (new RememberTokenModel())->verifyToken($token)) {
|
||||
session()->set([
|
||||
'logged_in' => true,
|
||||
'user_email' => (string) env('user_email'),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return redirect()->to('/login')->deleteCookie('remember_token', '', '/');
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Parsowanie paragonu przez OpenAI Responses API (/v1/responses).
|
||||
* Wejscie: obraz (input_image), PDF (input_file — OpenAI renderuje strony), JSON/tekst (input_text).
|
||||
* Wyjscie: merchant, date, total, items[] z proponowana kategoria (nazwa lub null).
|
||||
*
|
||||
* UWAGA: chat-completions z file_data NIE dostarcza tresci PDF (model halucynuje).
|
||||
* Dziala dopiero Responses API + input_file. gpt-4o czyta gesty paragon lepiej niz mini.
|
||||
* ponytail: jeden POST przez \Config\Services::curlrequest() — bez SDK.
|
||||
*/
|
||||
class ReceiptParser
|
||||
{
|
||||
private string $apiKey;
|
||||
private string $model;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = (string) env('OPEN_AI_API');
|
||||
// o4-mini (model rozumujacy) czyta gesty paragon fiskalny najdokladniej — lepiej niz gpt-4o/4.1
|
||||
// i tansze; poprawnie liczy rabaty (Opust). Podmiana przez env('openai.model').
|
||||
$this->model = (string) (env('openai.model') ?: 'o4-mini');
|
||||
|
||||
if ($this->apiKey === '') {
|
||||
throw new RuntimeException('Brak klucza OPEN_AI_API w .env.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $filePath sciezka do wgranego pliku
|
||||
* @param string $mime typ MIME pliku
|
||||
* @param string[] $categories dozwolone nazwy kategorii wydatkow (do dopasowania)
|
||||
*
|
||||
* @return array{merchant:?string,date:?string,total:?float,items:array<int,array{name:string,qty:?float,amount:float,category:?string}>}
|
||||
*/
|
||||
public function parse(string $filePath, string $mime, array $categories): array
|
||||
{
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException('Nie udalo sie odczytac pliku paragonu.');
|
||||
}
|
||||
|
||||
$parts = [['type' => 'input_text', 'text' => $this->prompt($categories)]];
|
||||
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
$parts[] = ['type' => 'input_image', 'image_url' => 'data:' . $mime . ';base64,' . base64_encode($content)];
|
||||
} elseif ($mime === 'application/pdf') {
|
||||
$parts[] = [
|
||||
'type' => 'input_file',
|
||||
'filename' => 'paragon.pdf',
|
||||
'file_data' => 'data:application/pdf;base64,' . base64_encode($content),
|
||||
];
|
||||
} else {
|
||||
// JSON (lub inny tekst) — przekaz tresc do interpretacji.
|
||||
$parts[] = ['type' => 'input_text', 'text' => "Dane paragonu (surowe):\n" . $content];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $this->model,
|
||||
'text' => ['format' => ['type' => 'json_object']],
|
||||
'input' => [['role' => 'user', 'content' => $parts]],
|
||||
];
|
||||
|
||||
$client = \Config\Services::curlrequest(['timeout' => 120]);
|
||||
$response = $client->post('https://api.openai.com/v1/responses', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => json_encode($payload),
|
||||
'http_errors' => false,
|
||||
]);
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
$body = (string) $response->getBody();
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
throw new RuntimeException('OpenAI API blad (HTTP ' . $status . '): ' . mb_substr($body, 0, 300));
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
$text = $this->extractText($decoded);
|
||||
if ($text === '') {
|
||||
throw new RuntimeException('Nieoczekiwana odpowiedz OpenAI (brak tresci).');
|
||||
}
|
||||
|
||||
$parsed = json_decode($text, true);
|
||||
if (! is_array($parsed)) {
|
||||
throw new RuntimeException('OpenAI zwrocilo niepoprawny JSON paragonu.');
|
||||
}
|
||||
|
||||
return $this->normalize($parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyciaga tekst z odpowiedzi Responses API (output_text lub output[].content[].text).
|
||||
*/
|
||||
private function extractText(?array $d): string
|
||||
{
|
||||
if (! is_array($d)) {
|
||||
return '';
|
||||
}
|
||||
if (! empty($d['output_text']) && is_string($d['output_text'])) {
|
||||
return $d['output_text'];
|
||||
}
|
||||
$txt = '';
|
||||
foreach ($d['output'] ?? [] as $o) {
|
||||
foreach ($o['content'] ?? [] as $c) {
|
||||
if (isset($c['text']) && is_string($c['text'])) {
|
||||
$txt .= $c['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $txt;
|
||||
}
|
||||
|
||||
private function prompt(array $categories): string
|
||||
{
|
||||
$list = implode(', ', $categories);
|
||||
|
||||
return <<<TXT
|
||||
To jest polski PARAGON FISKALNY (kolumny tabeli pozycji: Nazwa, PTU, Ilosc, Cena, Wartosc).
|
||||
Zwroc WYLACZNIE obiekt JSON o strukturze:
|
||||
{"merchant":string|null,"date":"YYYY-MM-DD"|null,"total":number|null,"items":[{"name":string,"display":string,"qty":number|null,"amount":number,"category":string|null}]}
|
||||
|
||||
Zasady:
|
||||
- "name" = nazwa DOKLADNIE jak na paragonie (skrocona, np. "WinogrJasneBezpLuz") — sluzy do mapowania.
|
||||
- "display" = ladna, czytelna nazwa produktu po polsku dla czlowieka (np. "Winogrona jasne bezpestkowe").
|
||||
- "amount" = kwota z kolumny WARTOSC (skrajnie prawa, laczna wartosc pozycji), NIE cena jednostkowa.
|
||||
- Linia "Opust"/"Rabat" ze znakiem minus: odejmij ja od wartosci poprzedniej pozycji (podaj amount po rabacie).
|
||||
- Ujmij pozycje z sekcji "Opakowania zwrotne"/kaucja, jesli wystepuja.
|
||||
- POMIN wiersze podsumowan: Sprzedaz opodatkowana, PTU, Suma PLN, DO ZAPLATY.
|
||||
- "date" = data zakupu z paragonu. "total" = kwota DO ZAPLATY.
|
||||
- "category" dobierz TYLKO z tej listy (dokladna nazwa) albo null: {$list}
|
||||
|
||||
Nie dodawaj zadnego tekstu poza obiektem JSON.
|
||||
TXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanityzacja i rzutowanie typow z odpowiedzi modelu.
|
||||
*/
|
||||
private function normalize(array $p): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($p['items'] ?? [] as $it) {
|
||||
$name = trim((string) ($it['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$display = trim((string) ($it['display'] ?? ''));
|
||||
$items[] = [
|
||||
'name' => $name,
|
||||
'display' => $display !== '' ? $display : $name,
|
||||
'qty' => isset($it['qty']) && $it['qty'] !== null ? (float) $it['qty'] : null,
|
||||
'amount' => round((float) ($it['amount'] ?? 0), 2),
|
||||
'category' => isset($it['category']) && $it['category'] !== null && $it['category'] !== ''
|
||||
? (string) $it['category'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
$date = isset($p['date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $p['date']) ? (string) $p['date'] : null;
|
||||
|
||||
return [
|
||||
'merchant' => isset($p['merchant']) && $p['merchant'] !== '' ? (string) $p['merchant'] : null,
|
||||
'date' => $date,
|
||||
'total' => isset($p['total']) && $p['total'] !== null ? round((float) $p['total'], 2) : null,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -60,15 +60,17 @@ class CategoryModel extends Model
|
||||
$indent = "\u{00A0}\u{00A0}\u{00A0}\u{00A0}"; // 4x twarda spacja na poziom
|
||||
$out = [];
|
||||
|
||||
$walk = static function (int $parentId, int $depth) use (&$walk, &$out, $childrenByParent, $indent): void {
|
||||
$walk = static function (int $parentId, int $depth, string $parentPath) use (&$walk, &$out, $childrenByParent, $indent): void {
|
||||
foreach ($childrenByParent[$parentId] ?? [] as $c) {
|
||||
$c['depth'] = $depth;
|
||||
$c['indent_label'] = str_repeat($indent, $depth) . $c['name'];
|
||||
// Pelna sciezka "Nadrzedna → Podrzedna" — jednoznaczna nawet gdy JS uciasnie wciecia.
|
||||
$c['path_label'] = $parentPath === '' ? $c['name'] : $parentPath . ' → ' . $c['name'];
|
||||
$out[] = $c;
|
||||
$walk((int) $c['id'], $depth + 1);
|
||||
$walk((int) $c['id'], $depth + 1, $c['path_label']);
|
||||
}
|
||||
};
|
||||
$walk(0, 0);
|
||||
$walk(0, 0, '');
|
||||
|
||||
// Sieroty (np. rodzic odfiltrowany typem) — dopnij na koncu bez wciecia
|
||||
$seen = array_column($out, null, 'id');
|
||||
@@ -76,6 +78,7 @@ class CategoryModel extends Model
|
||||
if (! isset($seen[$c['id']])) {
|
||||
$c['depth'] = 0;
|
||||
$c['indent_label'] = $c['name'];
|
||||
$c['path_label'] = $c['name'];
|
||||
$out[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Uczona pamiec dopasowan: znormalizowana nazwa pozycji -> kategoria.
|
||||
* Sluzy WYLACZNIE do podpowiedzi; nic nie jest ksiegowane automatycznie.
|
||||
*/
|
||||
class ReceiptCategoryMapModel extends Model
|
||||
{
|
||||
protected $table = 'receipt_category_map';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $dateFormat = 'datetime';
|
||||
protected $createdField = ''; // tabela ma tylko updated_at
|
||||
protected $allowedFields = ['item_name', 'category_id', 'updated_at'];
|
||||
|
||||
/**
|
||||
* Normalizacja nazwy pozycji jako klucz uczenia:
|
||||
* trim + lowercase + redukcja wielokrotnych bialych znakow.
|
||||
* ponytail: bez usuwania gramatur/liczb — zaczynamy od dokladnego dopasowania nazwy.
|
||||
*/
|
||||
public function normalize(string $name): string
|
||||
{
|
||||
$n = mb_strtolower(trim($name));
|
||||
|
||||
return preg_replace('/\s+/u', ' ', $n) ?? $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Podpowiedziana kategoria dla nazwy pozycji lub null.
|
||||
*/
|
||||
public function suggest(string $name): ?int
|
||||
{
|
||||
$row = $this->where('item_name', $this->normalize($name))->first();
|
||||
|
||||
return $row ? (int) $row['category_id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapamietaj wybor uzytkownika (upsert po znormalizowanej nazwie).
|
||||
*/
|
||||
public function remember(string $name, int $categoryId): void
|
||||
{
|
||||
$key = $this->normalize($name);
|
||||
if ($key === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->where('item_name', $key)->first();
|
||||
if ($existing) {
|
||||
$this->update($existing['id'], ['category_id' => $categoryId, 'updated_at' => date('Y-m-d H:i:s')]);
|
||||
} else {
|
||||
$this->insert(['item_name' => $key, 'category_id' => $categoryId, 'updated_at' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ReceiptItemModel extends Model
|
||||
{
|
||||
protected $table = 'receipt_items';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = [
|
||||
'receipt_id', 'name', 'display_name', 'qty', 'amount',
|
||||
'suggested_category_id', 'chosen_category_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Pozycje danego paragonu w kolejnosci wprowadzenia.
|
||||
*/
|
||||
public function forReceipt(int $receiptId): array
|
||||
{
|
||||
return $this->where('receipt_id', $receiptId)
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ReceiptModel extends Model
|
||||
{
|
||||
protected $table = 'receipts';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = [
|
||||
'file_path', 'original_name', 'mime', 'merchant',
|
||||
'receipt_date', 'total', 'status', 'raw_json', 'error_msg',
|
||||
];
|
||||
|
||||
public function hasOriginalName(string $name): bool
|
||||
{
|
||||
return $this->where('original_name', $name)->countAllResults() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista paragonow do historii, z liczba pozycji i utworzonych operacji.
|
||||
*/
|
||||
public function withCounts(): array
|
||||
{
|
||||
return $this->db->table('receipts r')
|
||||
->select('r.*')
|
||||
->select('(SELECT COUNT(*) FROM receipt_items ri WHERE ri.receipt_id = r.id) AS items_count', false)
|
||||
->select('(SELECT COUNT(*) FROM operations o WHERE o.receipt_id = r.id) AS operations_count', false)
|
||||
->orderBy('r.id', 'DESC')
|
||||
->get()->getResultArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class RememberTokenModel extends Model
|
||||
{
|
||||
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
protected $table = 'remember_tokens';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = ['selector', 'validator_hash', 'expires_at'];
|
||||
|
||||
public function issueToken(): string
|
||||
{
|
||||
$selector = bin2hex(random_bytes(16));
|
||||
$validator = bin2hex(random_bytes(32));
|
||||
|
||||
$this->insert([
|
||||
'selector' => $selector,
|
||||
'validator_hash' => hash('sha256', $validator),
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + self::LIFETIME_SECONDS),
|
||||
]);
|
||||
|
||||
return $selector . ':' . $validator;
|
||||
}
|
||||
|
||||
public function verifyToken(string $token): bool
|
||||
{
|
||||
[$selector, $validator] = $this->splitToken($token);
|
||||
if ($selector === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = $this->where('selector', $selector)->first();
|
||||
if (!$row || strtotime($row['expires_at']) <= time()) {
|
||||
if ($row) {
|
||||
$this->delete($row['id']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($row['validator_hash'], hash('sha256', $validator));
|
||||
}
|
||||
|
||||
public function deleteToken(string $token): void
|
||||
{
|
||||
[$selector] = $this->splitToken($token);
|
||||
if ($selector !== null) {
|
||||
$this->where('selector', $selector)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteExpired(): void
|
||||
{
|
||||
$this->where('expires_at <=', date('Y-m-d H:i:s'))->delete();
|
||||
}
|
||||
|
||||
private function splitToken(string $token): array
|
||||
{
|
||||
$parts = explode(':', $token, 2);
|
||||
if (count($parts) !== 2 || !ctype_xdigit($parts[0]) || strlen($parts[0]) !== 32
|
||||
|| !ctype_xdigit($parts[1]) || strlen($parts[1]) !== 64) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
<label class="form-label">Hasło</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" name="remember" value="1" class="form-check-input" id="remember">
|
||||
<label class="form-check-label" for="remember">Zapamiętaj mnie przez 30 dni</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Zaloguj</button>
|
||||
<?= form_close() ?>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<ul class="dropdown-menu" aria-labelledby="navFinance">
|
||||
<li><a class="dropdown-item" href="<?= site_url('finances') ?>">Pulpit finansów</a></li>
|
||||
<li><a class="dropdown-item" href="<?= site_url('operations') ?>">Operacje</a></li>
|
||||
<li><a class="dropdown-item" href="<?= site_url('receipts') ?>">Import paragonów</a></li>
|
||||
<li><a class="dropdown-item" href="<?= site_url('categories') ?>">Kategorie</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -55,6 +56,9 @@
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (session()->getFlashdata('warning')): ?>
|
||||
<div class="alert alert-warning"><?= esc(session()->getFlashdata('warning')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?= $this->renderSection('content') ?>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?= $this->extend('layout/main') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?php
|
||||
$fmt = static fn ($v) => number_format((float) $v, 2, ',', ' ') . ' zł';
|
||||
$badges = [
|
||||
'parsing' => 'secondary',
|
||||
'parsed' => 'info',
|
||||
'failed' => 'danger',
|
||||
'imported' => 'success',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">Import paragonów</h1>
|
||||
<a href="<?= site_url('receipts/new') ?>" class="btn btn-primary">+ Nowy import</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Sprzedawca</th>
|
||||
<th class="text-end">Suma</th>
|
||||
<th class="text-center">Pozycje</th>
|
||||
<th class="text-center">Operacje</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($receipts)): ?>
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Brak zaimportowanych paragonów.</td></tr>
|
||||
<?php else: foreach ($receipts as $r): ?>
|
||||
<tr>
|
||||
<td><?= esc($r['receipt_date'] ?: mb_substr((string) $r['created_at'], 0, 10)) ?></td>
|
||||
<td><?= esc($r['merchant'] ?: '—') ?></td>
|
||||
<td class="text-end"><?= $r['total'] !== null ? $fmt($r['total']) : '—' ?></td>
|
||||
<td class="text-center"><?= (int) $r['items_count'] ?></td>
|
||||
<td class="text-center"><?= (int) $r['operations_count'] ?></td>
|
||||
<td><span class="badge bg-<?= $badges[$r['status']] ?? 'secondary' ?>"><?= esc($r['status']) ?></span></td>
|
||||
<td class="text-end">
|
||||
<?php if ($r['status'] === 'parsed'): ?>
|
||||
<a href="<?= site_url('receipts/' . $r['id'] . '/review') ?>" class="btn btn-sm btn-outline-primary">Przegląd</a>
|
||||
<?php else: ?>
|
||||
<a href="<?= site_url('receipts/' . $r['id']) ?>" class="btn btn-sm btn-outline-secondary">Podgląd</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= site_url('receipts/' . $r['id'] . '/delete') ?>" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Usunąć import?')">Usuń</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?= $this->extend('layout/main') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">Nowy import paragonu</h1>
|
||||
<a href="<?= site_url('receipts') ?>" class="btn btn-link">← Historia importów</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<?= form_open_multipart('receipts', ['class' => 'row g-3']) ?>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Plik paragonu</label>
|
||||
<input type="file" name="receipt" class="form-control" required
|
||||
accept=".jpg,.jpeg,.png,.pdf,.json,image/*,application/pdf,application/json">
|
||||
<div class="form-text">
|
||||
Obsługiwane formaty: skan JPG/PNG, PDF lub JSON. Maks. 10 MB.
|
||||
Po wgraniu AI odczyta pozycje i zaproponuje kategorie do zatwierdzenia.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Wgraj i przetwórz</button>
|
||||
</div>
|
||||
<?= form_close() ?>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,116 @@
|
||||
<?= $this->extend('layout/main') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?php $fmt = static fn ($v) => number_format((float) $v, 2, ',', ' ') . ' zł'; ?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">Przegląd paragonu</h1>
|
||||
<a href="<?= site_url('receipts') ?>" class="btn btn-link">← Historia importów</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body row g-2">
|
||||
<div class="col-md-4"><span class="text-muted small d-block">Sprzedawca</span><?= esc($receipt['merchant'] ?: '—') ?></div>
|
||||
<div class="col-md-4"><span class="text-muted small d-block">Data</span><?= esc($receipt['receipt_date'] ?: '—') ?></div>
|
||||
<div class="col-md-4"><span class="text-muted small d-block">Suma z paragonu (AI)</span><?= $receipt['total'] !== null ? $fmt($receipt['total']) : '—' ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= form_open('receipts/' . $receipt['id'] . '/confirm') ?>
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white">
|
||||
<strong>Pozycje</strong> — sprawdź i popraw nazwy/kwoty (AI potrafi się pomylić), przypisz kategorię
|
||||
(podpowiedź zaznaczona; „— pomiń —” = nie księguj)
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40%">Pozycja</th>
|
||||
<th class="text-end" style="width:16%">Kwota (zł)</th>
|
||||
<th>Kategoria</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($items)): ?>
|
||||
<tr><td colspan="3" class="text-center text-muted py-4">AI nie odczytało żadnych pozycji.</td></tr>
|
||||
<?php else: foreach ($items as $it): ?>
|
||||
<?php $sel = $it['chosen_category_id'] ?? $it['suggested_category_id']; ?>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" name="display[<?= $it['id'] ?>]" class="form-control form-control-sm"
|
||||
value="<?= esc($it['display_name'] ?: $it['name']) ?>" maxlength="255">
|
||||
<span class="text-muted small">z paragonu: <?= esc($it['name']) ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0" name="amount[<?= $it['id'] ?>]"
|
||||
class="form-control form-control-sm text-end js-amount"
|
||||
value="<?= esc(number_format((float) $it['amount'], 2, '.', '')) ?>">
|
||||
</td>
|
||||
<td>
|
||||
<select name="chosen_category_id[<?= $it['id'] ?>]" class="form-select form-select-sm js-cat">
|
||||
<option value="">— pomiń —</option>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option value="<?= $c['id'] ?>" <?= (string) $sel === (string) $c['id'] ? 'selected' : '' ?>>
|
||||
<?= esc($c['path_label']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white"><strong>Podgląd księgowania</strong> — jedna operacja na kategorię (suma)</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group" id="groupPreview"><li class="list-group-item text-muted">—</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<button type="submit" class="btn btn-primary">Zaksięguj</button>
|
||||
<a href="<?= site_url('receipts/' . $receipt['id'] . '/delete') ?>" class="btn btn-outline-danger"
|
||||
onclick="return confirm('Usunąć ten import?')">Usuń import</a>
|
||||
</div>
|
||||
<?= form_close() ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
// Podglad grupowania po kategorii liczony na zywo z pol wiersza (vanilla, bez zaleznosci).
|
||||
const fmt = (v) => v.toLocaleString('pl-PL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' zł';
|
||||
const rows = Array.from(document.querySelectorAll('tbody tr')).filter((r) => r.querySelector('.js-cat'));
|
||||
const preview = document.getElementById('groupPreview');
|
||||
|
||||
function labelFor(sel) {
|
||||
return sel.options[sel.selectedIndex].text.trim();
|
||||
}
|
||||
function render() {
|
||||
const groups = {};
|
||||
rows.forEach((row) => {
|
||||
const sel = row.querySelector('.js-cat');
|
||||
if (!sel || !sel.value) return;
|
||||
const amt = parseFloat(row.querySelector('.js-amount').value) || 0;
|
||||
if (!groups[sel.value]) groups[sel.value] = { label: labelFor(sel), sum: 0 };
|
||||
groups[sel.value].sum += amt;
|
||||
});
|
||||
const keys = Object.keys(groups);
|
||||
if (!keys.length) {
|
||||
preview.innerHTML = '<li class="list-group-item text-muted">Brak przypisanych pozycji — nic nie zostanie zaksięgowane.</li>';
|
||||
return;
|
||||
}
|
||||
preview.innerHTML = keys.map((k) =>
|
||||
`<li class="list-group-item d-flex justify-content-between"><span>${groups[k].label}</span><strong>${fmt(groups[k].sum)}</strong></li>`
|
||||
).join('');
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
row.querySelector('.js-cat').addEventListener('change', render);
|
||||
row.querySelector('.js-amount').addEventListener('input', render);
|
||||
});
|
||||
render();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?= $this->extend('layout/main') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?php $fmt = static fn ($v) => number_format((float) $v, 2, ',', ' ') . ' zł'; ?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">Paragon <?= esc($receipt['merchant'] ?: '#' . $receipt['id']) ?></h1>
|
||||
<a href="<?= site_url('receipts') ?>" class="btn btn-link">← Historia importów</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body row g-2">
|
||||
<div class="col-6"><span class="text-muted small d-block">Data</span><?= esc($receipt['receipt_date'] ?: '—') ?></div>
|
||||
<div class="col-6"><span class="text-muted small d-block">Suma (AI)</span><?= $receipt['total'] !== null ? $fmt($receipt['total']) : '—' ?></div>
|
||||
<div class="col-6"><span class="text-muted small d-block">Status</span><?= esc($receipt['status']) ?></div>
|
||||
<div class="col-6"><span class="text-muted small d-block">Plik</span>
|
||||
<a href="<?= site_url('receipts/' . $receipt['id'] . '/file') ?>" target="_blank"><?= esc($receipt['original_name']) ?></a>
|
||||
</div>
|
||||
<?php if ($receipt['status'] === 'failed' && $receipt['error_msg']): ?>
|
||||
<div class="col-12"><div class="alert alert-danger mb-0 mt-2"><?= esc($receipt['error_msg']) ?></div></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white"><strong>Pozycje</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead class="table-light"><tr><th>Pozycja</th><th class="text-end">Kwota</th></tr></thead>
|
||||
<tbody>
|
||||
<?php if (empty($items)): ?>
|
||||
<tr><td colspan="2" class="text-center text-muted py-3">Brak pozycji.</td></tr>
|
||||
<?php else: foreach ($items as $it): ?>
|
||||
<tr><td><?= esc($it['display_name'] ?: $it['name']) ?></td><td class="text-end"><?= $fmt($it['amount']) ?></td></tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<?php if ($receipt['status'] === 'parsed'): ?>
|
||||
<a href="<?= site_url('receipts/' . $receipt['id'] . '/review') ?>" class="btn btn-primary">Przejdź do przeglądu</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= site_url('receipts/' . $receipt['id'] . '/delete') ?>" class="btn btn-outline-danger"
|
||||
onclick="return confirm('Usunąć ten import?')">Usuń import</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<?php if ($receipt['status'] === 'imported'): ?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white"><strong>Zaksięgowane operacje</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<?php foreach ($operations as $o): ?>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span><?= esc($o['category_name'] ?: '(bez kategorii)') ?></span>
|
||||
<strong class="amount-expense"><?= $fmt($o['amount']) ?></strong>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (str_starts_with((string) $receipt['mime'], 'image/')): ?>
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header bg-white"><strong>Skan</strong></div>
|
||||
<div class="card-body text-center">
|
||||
<img src="<?= site_url('receipts/' . $receipt['id'] . '/file') ?>" class="img-fluid" alt="Skan paragonu">
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
Reference in New Issue
Block a user