This commit is contained in:
2026-07-12 19:21:35 +02:00
parent f2d944b117
commit 41cb412cb0
49 changed files with 3170 additions and 52 deletions
+10
View File
@@ -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');
+24 -2
View File
@@ -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', '', '/');
}
}
+307
View File
@@ -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);
}
}
+15 -2
View File
@@ -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)
+176
View File
@@ -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,
];
}
}
+6 -3
View File
@@ -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;
}
}
+60
View File
@@ -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')]);
}
}
}
+27
View File
@@ -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();
}
}
+35
View File
@@ -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();
}
}
+73
View File
@@ -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;
}
}
+4
View File
@@ -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>
+4
View File
@@ -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>
+59
View File
@@ -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() ?>
+27
View File
@@ -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() ?>
+116
View File
@@ -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() ?>
+77
View File
@@ -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() ?>