update
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Auto-generated by codebase-memory-mcp
|
||||||
|
# Prevent merge conflicts on compressed artifact
|
||||||
|
graph.db.zst merge=ours binary
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"commit": "",
|
||||||
|
"indexed_at": "2026-07-12T14:46:44Z",
|
||||||
|
"project": "C-visual-studio-code-finansePRO",
|
||||||
|
"nodes": 789,
|
||||||
|
"edges": 1015,
|
||||||
|
"original_size": 2424832,
|
||||||
|
"compressed_size": 315382,
|
||||||
|
"compression_level": 9
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -14,3 +14,10 @@ CI_ENVIRONMENT=production
|
|||||||
# Bez app.baseURL CI4 uzyje wartosci z app/Config/App.php (localhost:8080) i linki beda zle.
|
# Bez app.baseURL CI4 uzyje wartosci z app/Config/App.php (localhost:8080) i linki beda zle.
|
||||||
app.baseURL = 'https://finanse.projectpro.pl/'
|
app.baseURL = 'https://finanse.projectpro.pl/'
|
||||||
# Do lokalnego dev: CI_ENVIRONMENT=development + zakomentuj powyzsze app.baseURL.
|
# Do lokalnego dev: CI_ENVIRONMENT=development + zakomentuj powyzsze app.baseURL.
|
||||||
|
|
||||||
|
#Open AI
|
||||||
|
OPEN_AI_API=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
|
||||||
|
|
||||||
|
# Model OpenAI do importu paragonow. Domyslnie o4-mini (rozumujacy, najlepszy OCR paragonow + rabaty).
|
||||||
|
# Alternatywy: gpt-4o (drozszy, slabszy tu), gpt-4o-mini (tani, halucynuje na gestym druku).
|
||||||
|
# openai.model = o4-mini
|
||||||
|
|||||||
+25
-14
@@ -1,21 +1,32 @@
|
|||||||
# PROJECT — finansePRO
|
# PROJECT — finansePRO
|
||||||
|
|
||||||
**Utworzono:** 2026-07-06
|
**Status:** Aktywny, plan-first
|
||||||
**Status:** Inicjalizacja (pusty projekt)
|
|
||||||
|
|
||||||
## Nazwa
|
## Opis
|
||||||
finansePRO
|
|
||||||
|
|
||||||
## Krótki opis
|
Jednoosobowa aplikacja finansowa w CodeIgniter 4.7.3 i MySQL do ewidencji finansów bieżących, inwestycji oraz półautomatycznego importu paragonów.
|
||||||
Projekt na wczesnym etapie — katalog nie zawiera jeszcze kodu (poza konfiguracją `.vscode`). Cel i zakres do doprecyzowania.
|
|
||||||
|
|
||||||
## Wartość podstawowa
|
## Dostarczone funkcje
|
||||||
_Do uzupełnienia — czego finansePRO ma dostarczać użytkownikowi._
|
|
||||||
|
|
||||||
## Aktualny status
|
- ✓ Wydatki, przychody, kategorie i pulpity finansowe — Plan 1.
|
||||||
- Repozytorium bez kodu źródłowego.
|
- ✓ Instrumenty, operacje, wyceny i pulpity inwestycji — Plan 2.
|
||||||
- PAUL zainicjalizowany w trybie plan-first.
|
- ✓ Import paragonów przez OpenAI Responses API z podpowiedziami kategorii — Plan 3.
|
||||||
- Brak zdefiniowanych wymagań i stosu technologicznego.
|
- ✓ Zapamiętywanie logowania przez 30 dni na wielu urządzeniach — Plan 4.
|
||||||
|
|
||||||
## Trwałe wymagania / ograniczenia
|
## Trwałe wymagania i ograniczenia
|
||||||
_Brak znanych na tę chwilę. Uzupełnij przy pierwszym `$paul-plan` lub `$paul-discuss`._
|
|
||||||
|
- Jedno konto z `.env`; brak systemu wielu użytkowników.
|
||||||
|
- MySQL na hostido; nowe migracje są stosowane ręcznie na zdalnej bazie.
|
||||||
|
- Lokalnie może brakować `vendor/`; wtedy dostępne jest `php -l`, a testy runtime wykonuje się w pełnym środowisku.
|
||||||
|
- Tokeny logowania są niezależne per urządzenie i przechowywane w bazie tylko jako hashe walidatorów.
|
||||||
|
|
||||||
|
## Kluczowe decyzje
|
||||||
|
|
||||||
|
| Data | Decyzja | Powód |
|
||||||
|
|------|---------|-------|
|
||||||
|
| 2026-07-12 | Osobny token „remember me” per urządzenie | Równoległe logowania bez wydłużania globalnej sesji |
|
||||||
|
| 2026-07-12 | Wylogowanie usuwa tylko bieżący token | Inne komputery mają pozostać zalogowane |
|
||||||
|
| 2026-07-12 | Bez panelu urządzeń i „wyloguj wszędzie” | YAGNI; brak takiego wymagania |
|
||||||
|
|
||||||
|
---
|
||||||
|
*Last updated: 2026-07-12 after Plan 20260712-1647*
|
||||||
|
|||||||
+27
-16
@@ -1,35 +1,46 @@
|
|||||||
# STATE — finansePRO
|
# STATE — finansePRO
|
||||||
|
|
||||||
**Aktualizacja:** 2026-07-06T14:55
|
**Aktualizacja:** 2026-07-12T17:31:28+02:00
|
||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
CodeIgniter 4.7.3 + MySQL (baza `host700513_finansepro` na hostido, czytana z `.env`). PHP 8.2 (XAMPP), Bootstrap 5 + ApexCharts (CDN). Logowanie: jeden użytkownik z `.env`. Tryb PAUL: plan-first.
|
|
||||||
|
CodeIgniter 4.7.3 + MySQL na hostido. Jedno konto z `.env`. Tryb PAUL: plan-first.
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
- Plan `20260706-1248-fundament-wydatki-przychody`: **UKOŃCZONY**.
|
|
||||||
- Plan `20260706-1430-modul-inwestycji`: **UKOŃCZONY** (PLAN → APPLY → UNIFY). Wszystkie AC (AC-1..AC-5) Pass; SUMMARY utworzone.
|
- Plan `20260712-1647-zapamietywanie-logowania`: **UKOŃCZONY**.
|
||||||
- Gotowe do kolejnego planu.
|
- Status: PLAN → APPLY → UNIFY zamknięte; gotowe do następnego planu.
|
||||||
|
- Dostarczono tokeny logowania ważne 30 dni, niezależne dla wielu urządzeń.
|
||||||
|
- Zdalna tabela `remember_tokens` została utworzona i zweryfikowana.
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
✓ ✓ ✓ [Pętla zamknięta — gotowe do następnego PLAN]
|
✓ ✓ ✓ [Pętla zamknięta — gotowe do następnego PLAN]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
- Ostatnia aktywność: UNIFY planu 2 — SUMMARY + changelog + radar + STATE. Moduł inwestycji + pulpit globalny `/dashboard` + pulpit finansów `/finances`. Dane testowe wyczyszczone, `.env` na `production`.
|
|
||||||
- Next action: `$paul-plan` dla kolejnego planu, lub wdrożenie produkcyjne (patrz Aktywne sprawy).
|
|
||||||
- Resume file: `.paul/plans/20260706-1430-modul-inwestycji/SUMMARY.md`
|
|
||||||
|
|
||||||
## Roadmapa (nieformalna)
|
- Last session: 2026-07-12T17:31:28+02:00
|
||||||
- ✓ Plan 1: fundament + wydatki/przychody bieżące.
|
- Stopped at: UNIFY planu zapamiętywania logowania.
|
||||||
- ✓ Plan 2: moduł inwestycji (instrumenty, wpłaty/wypłaty, wyceny) + pulpit inwestycji + pulpit globalny.
|
- Next action: opcjonalnie potwierdzić UAT w dwóch profilach albo uruchomić `$paul-plan` dla kolejnej funkcji.
|
||||||
- ▷ Kolejne: do doprecyzowania (np. wykres per instrument, eksport, historia wartości).
|
- Resume file: `.paul/plans/20260712-1647-zapamietywanie-logowania/SUMMARY.md`
|
||||||
|
|
||||||
## Aktywne sprawy / do weryfikacji
|
## Aktywne sprawy / do weryfikacji
|
||||||
- Wdrożenie produkcyjne `https://finanse.projectpro.pl/`: wgrać pliki + `vendor/`, uruchomić `php spark migrate` na serwerze (3 nowe tabele `inv_*`), root `.htaccess`/docroot→`public/`, prawa `writable/`, `.env` z `CI_ENVIRONMENT=production` + `app.baseURL`.
|
|
||||||
- Lokalny dev: `.env` bywa przełączany na `CI_ENVIRONMENT=development` (używa `db_host_remote`, bo lokalny MySQL zgaszony); po pracy wraca na `production`. Aktualnie: `production`.
|
- UAT: dwa profile przeglądarki, zamknięcie zwykłej sesji oraz wylogowanie tylko jednego urządzenia.
|
||||||
|
- `vendor/bin/phpunit` nie jest dostępny lokalnie; test `RememberTokenModelTest.php` został tylko sprawdzony przez `php -l`.
|
||||||
|
- Deploy hostido nie uruchamia migracji automatycznie; tabela dla tego planu została już utworzona zdalnie.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Token per urządzenie; kolejne logowanie nie usuwa wcześniejszych tokenów.
|
||||||
|
- Wylogowanie usuwa wyłącznie token bieżącego cookie.
|
||||||
|
- Bez panelu urządzeń i globalnego wylogowania do czasu realnej potrzeby.
|
||||||
|
|
||||||
## Uwagi
|
## Uwagi
|
||||||
- Radar: degraded (weryfikacja runtime zamiast pełnego indeksu). Pełny `codebase-memory-mcp` przez `$paul-map-codebase`.
|
|
||||||
- Repo nie jest git — brak commitów fazowych.
|
- Radar post-apply: degraded z powodu niepełnego wyniku indeksu i braku PHPUnit.
|
||||||
|
- Repozytorium Git ma liczne wcześniejsze i niezwiązane zmiany; commit fazowy pominięty zgodnie z `preferences.auto_commit: false`.
|
||||||
|
- `ROADMAP.md` nie istnieje i pozostaje opcjonalny w trybie plan-first.
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
- Pulpit globalny `/dashboard` (majątek razem = saldo bieżące + wartość inwestycji); dotychczasowy pulpit finansów przepięty pod `/finances`.
|
- Pulpit globalny `/dashboard` (majątek razem = saldo bieżące + wartość inwestycji); dotychczasowy pulpit finansów przepięty pod `/finances`.
|
||||||
- Nawigacja zgrupowana w dropdowny: „Finanse bieżące" i „Inwestycje" + top-level „Pulpit".
|
- Nawigacja zgrupowana w dropdowny: „Finanse bieżące" i „Inwestycje" + top-level „Pulpit".
|
||||||
- Weryfikacja end-to-end curl (ETF: netto 800, wartość 950, zarobek 150 = 18,75%); dane testowe wyczyszczone.
|
- Weryfikacja end-to-end curl (ETF: netto 800, wartość 950, zarobek 150 = 18,75%); dane testowe wyczyszczone.
|
||||||
|
- [Plan 20260706-2046] Półautomatyczny import paragonów: upload (jpg/png/pdf/json) → AI parsuje → przegląd/korekta → księgowanie pogrupowane po kategorii.
|
||||||
|
- Parser OpenAI przez **Responses API** (`/v1/responses` + `input_file`), model **o4-mini** (rozumujący — najlepszy OCR paragonów, poprawne rabaty; > gpt-4o/4.1).
|
||||||
|
- Tabele `receipts`, `receipt_items` (+`display_name`), `receipt_category_map`; kolumna `operations.receipt_id`.
|
||||||
|
- Uczone podpowiedzi kategorii (surowa nazwa z paragonu → kategoria; tylko podpowiada, nie księguje z automatu).
|
||||||
|
- Dwie nazwy pozycji: surowa (mapowanie) + ładna „ludzka" (wyświetlanie/opis operacji).
|
||||||
|
- Przegląd z edytowalnymi nazwami/kwotami + podgląd sum na żywo; kategorie z pełną ścieżką „Nadrzędna → Podrzędna".
|
||||||
|
- Naprawy pod hostido: przepływ bez `fileinfo` (własna nazwa pliku + MIME z rozszerzenia); migracje zastosowane ręcznie na zdalnej bazie.
|
||||||
|
- UAT realnym paragonem (Biedronka PDF) potwierdzony przez użytkownika.
|
||||||
|
|
||||||
## Zmienione pliki
|
## Zmienione pliki
|
||||||
|
|
||||||
@@ -37,3 +45,8 @@
|
|||||||
- `app/Views/instruments/{index,form}.php`, `app/Views/inv_operations/{index,form}.php`, `app/Views/valuations/{index,form}.php`, `app/Views/investments/index.php`
|
- `app/Views/instruments/{index,form}.php`, `app/Views/inv_operations/{index,form}.php`, `app/Views/valuations/{index,form}.php`, `app/Views/investments/index.php`
|
||||||
- `app/Views/dashboard/index.php` (globalny), `app/Views/dashboard/finances.php` (finansowy)
|
- `app/Views/dashboard/index.php` (globalny), `app/Views/dashboard/finances.php` (finansowy)
|
||||||
- `app/Config/Routes.php`, `app/Views/layout/main.php`
|
- `app/Config/Routes.php`, `app/Views/layout/main.php`
|
||||||
|
- `app/Database/Migrations/2026-07-06-2000{01..05}_*.php` (receipts, receipt_items, receipt_category_map, operations.receipt_id, display_name)
|
||||||
|
- `app/Models/{ReceiptModel,ReceiptItemModel,ReceiptCategoryMapModel}.php`, `app/Models/OperationModel.php`, `app/Models/CategoryModel.php`
|
||||||
|
- `app/Libraries/ReceiptParser.php`
|
||||||
|
- `app/Controllers/Receipts.php`
|
||||||
|
- `app/Views/receipts/{new,review,index,show}.php`
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 2026-07-12
|
||||||
|
|
||||||
|
## Co zrobiono
|
||||||
|
|
||||||
|
- [Plan 20260712-1647] Dodano zapamiętywanie logowania przez 30 dni.
|
||||||
|
- Dodano niezależne, hashowane tokeny dla wielu komputerów.
|
||||||
|
- Podłączono checkbox, odtwarzanie sesji i selektywne wylogowanie.
|
||||||
|
- Utworzono tabelę `remember_tokens` na zdalnej bazie.
|
||||||
|
- Dodano test modelu; jego uruchomienie odroczono z powodu braku lokalnego `vendor/`.
|
||||||
|
|
||||||
|
## Zmienione pliki
|
||||||
|
|
||||||
|
- `app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php`
|
||||||
|
- `app/Models/RememberTokenModel.php`
|
||||||
|
- `app/Controllers/Auth.php`
|
||||||
|
- `app/Filters/AuthFilter.php`
|
||||||
|
- `app/Views/auth/login.php`
|
||||||
|
- `tests/unit/RememberTokenModelTest.php`
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Stos
|
||||||
|
|
||||||
|
- CodeIgniter 4.7.3, PHP 8.2, MySQL, Bootstrap 5.
|
||||||
|
- Jedno konto aplikacji pobierane z `.env` (`user_email`, `user_password`).
|
||||||
|
|
||||||
|
## Uwierzytelnianie
|
||||||
|
|
||||||
|
- `Auth::login` tworzy zwykłą sesję; opcjonalnie wystawia trwały token.
|
||||||
|
- `AuthFilter::before` chroni trasy i może odtworzyć sesję z cookie `remember_token`.
|
||||||
|
- `Auth::logout` usuwa sesję oraz token tylko bieżącego urządzenia.
|
||||||
|
- `RememberTokenModel` jest jednym źródłem logiki tokenów i czasu życia 30 dni.
|
||||||
|
|
||||||
|
## Granice
|
||||||
|
|
||||||
|
- Brak tabeli użytkowników i systemu wielu kont.
|
||||||
|
- Moduły finansów, inwestycji i paragonów korzystają wyłącznie z wyniku filtra `auth`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Database Schema
|
||||||
|
|
||||||
|
## `remember_tokens`
|
||||||
|
|
||||||
|
| Kolumna | Typ | Uwagi |
|
||||||
|
|---------|-----|-------|
|
||||||
|
| `id` | INT UNSIGNED | Primary key, auto increment |
|
||||||
|
| `selector` | VARCHAR(32) | Unique, jawna część identyfikacyjna tokenu |
|
||||||
|
| `validator_hash` | CHAR(64) | SHA-256 walidatora; surowy walidator nie jest zapisywany |
|
||||||
|
| `expires_at` | DATETIME | Indeksowany termin ważności |
|
||||||
|
| `created_at` | DATETIME NULL | Timestamp CodeIgniter |
|
||||||
|
| `updated_at` | DATETIME NULL | Timestamp CodeIgniter |
|
||||||
|
|
||||||
|
Tabela przechowuje wiele aktywnych rekordów, dzięki czemu urządzenia pozostają zalogowane niezależnie.
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
# Impact Map
|
# Impact Map
|
||||||
|
|
||||||
|
## Post-apply 20260712-1647
|
||||||
|
- Mechanizm wdrożony w kontrolerze logowania, filtrze, widoku, modelu i migracji.
|
||||||
|
- Zdalna tabela `remember_tokens` istnieje.
|
||||||
|
- Trasy i globalna konfiguracja sesji pozostały bez zmian.
|
||||||
|
- Do ręcznego potwierdzenia pozostaje UAT dwóch profili przeglądarki.
|
||||||
|
|
||||||
|
## Plan 20260712-1647 — zapamiętywanie logowania (plan scan)
|
||||||
|
- Logowanie: `app/Controllers/Auth.php`, `app/Views/auth/login.php`.
|
||||||
|
- Odtworzenie sesji: `app/Filters/AuthFilter.php`; trasy pozostają bez zmian.
|
||||||
|
- Dane: nowa tabela tokenów i model; wiele rekordów umożliwia wiele urządzeń.
|
||||||
|
- Konfiguracja: użyć istniejących ustawień HttpOnly/SameSite; nie wydłużać `Session::$expiration`.
|
||||||
|
- Test/UAT: dwa profile, wylogowanie jednego urządzenia, nieważny i wygasły token.
|
||||||
|
- Wdrożenie: migrację zastosować ręcznie na hostido przed kodem.
|
||||||
|
|
||||||
**Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded)
|
**Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded)
|
||||||
|
|
||||||
## Kontekst
|
## Kontekst
|
||||||
@@ -16,6 +30,15 @@ CodeIgniter 4.7.3 + MySQL (`.env`). Radar degraded — brak indeksu codebase-mem
|
|||||||
- Wyceny: `ValuationModel`, `Valuations.php`, widoki `valuations/`, tabela `inv_valuations` (FK RESTRICT → inv_instruments).
|
- Wyceny: `ValuationModel`, `Valuations.php`, widoki `valuations/`, tabela `inv_valuations` (FK RESTRICT → inv_instruments).
|
||||||
- Pulpit inwestycji: `Investments::index` → `investments/index.php`, trasa `/investments`.
|
- Pulpit inwestycji: `Investments::index` → `investments/index.php`, trasa `/investments`.
|
||||||
|
|
||||||
|
### Import paragonów (Plan 3)
|
||||||
|
- Paragony: `ReceiptModel` (`withCounts`), `ReceiptItemModel` (`forReceipt`, `name` surowa + `display_name` ładna), `Receipts.php`, widoki `receipts/`, tabele `receipts` + `receipt_items` (FK CASCADE → receipts, SET NULL → categories).
|
||||||
|
- Uczone dopasowania: `ReceiptCategoryMapModel` (`normalize`, `suggest`, `remember`), tabela `receipt_category_map` (item_name UNIQUE → category_id). Klucz = surowa nazwa z paragonu. Tylko podpowiedź, brak auto-zapisu.
|
||||||
|
- Parser AI: `app/Libraries/ReceiptParser.php` — OpenAI **Responses API** (`/v1/responses`) przez `\Config\Services::curlrequest()`; jpg/png (`input_image`), pdf (`input_file` — OpenAI renderuje strony), json (`input_text`). Model z `env('openai.model')` domyślnie **o4-mini** (rozumujący; mini/gpt-4o za słabe na gęsty druk). Zwraca `name`+`display`. Klucz `OPEN_AI_API`.
|
||||||
|
- Upload bez `fileinfo` (hostido je wyłącza): walidacja rozszerzeń ręczna, własna nazwa pliku, MIME z rozszerzenia. Cały przepływ w try/catch → błąd zapisany w `receipts.error_msg`, nie 500.
|
||||||
|
- Zapis: `confirm()` grupuje pozycje po kategorii → 1 operacja `expense`/kategoria (suma edytowalnych kwot) przez `OperationModel`, z `operations.receipt_id` (FK SET NULL). Uczenie na surowej nazwie, opis z ładnych. Transakcja DB.
|
||||||
|
- Kategorie: `CategoryModel::tree()` daje `path_label` („Nadrzędna → Podrzędna") — używane w przeglądzie paragonu; `indent_label` nadal w operacjach.
|
||||||
|
- Pliki skanów: `writable/uploads/receipts/`, serwowane przez `Receipts::file` pod filtrem `auth`.
|
||||||
|
|
||||||
### Pulpit globalny
|
### Pulpit globalny
|
||||||
- `Dashboard::index` → `app/Views/dashboard/index.php`, trasy `/` i `/dashboard`. Majątek = saldo bieżące (`OperationModel::totals`) + wartość inwestycji (`InstrumentModel::withStats`).
|
- `Dashboard::index` → `app/Views/dashboard/index.php`, trasy `/` i `/dashboard`. Majątek = saldo bieżące (`OperationModel::totals`) + wartość inwestycji (`InstrumentModel::withStats`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Quality Risks
|
# Quality Risks
|
||||||
|
|
||||||
|
## Post-apply 20260712-1647
|
||||||
|
- **Obsłużone:** osobne tokeny urządzeń, hash walidatora, `hash_equals`, selektywne wylogowanie.
|
||||||
|
- **Obsłużone operacyjnie:** tabela utworzona na zdalnej bazie po wykryciu błędu braku migracji.
|
||||||
|
- **Odroczone:** PHPUnit i UAT dwóch profili; brak lokalnego `vendor/` i potwierdzenia użytkownika.
|
||||||
|
|
||||||
|
## Plan 20260712-1647 — zapamiętywanie logowania
|
||||||
|
- **Sekret tokenu:** w bazie wyłącznie hash walidatora; porównanie przez `hash_equals`.
|
||||||
|
- **Wiele urządzeń:** nowe logowanie dopisuje token, nie zastępuje aktywnych tokenów.
|
||||||
|
- **Wylogowanie:** unieważnia tylko token bieżącego cookie; sesja i cookie są usuwane.
|
||||||
|
- **Cookie:** 30 dni, HttpOnly, SameSite=Lax, Secure na produkcji.
|
||||||
|
- **Jedno źródło czasu życia:** stała w modelu tokenów.
|
||||||
|
- **YAGNI:** bez panelu urządzeń, harmonogramu i globalnego wylogowania.
|
||||||
|
|
||||||
**Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded)
|
**Aktualizacja:** 2026-07-06 (post-apply Plan 2 — degraded)
|
||||||
|
|
||||||
## Zaakceptowane świadomie
|
## Zaakceptowane świadomie
|
||||||
@@ -15,5 +28,14 @@
|
|||||||
- Pełna indeksacja `codebase-memory-mcp` — `$paul-map-codebase`.
|
- Pełna indeksacja `codebase-memory-mcp` — `$paul-map-codebase`.
|
||||||
- jscpd/ast-grep — wyłączone polityką w `config.md` (skan on-demand).
|
- jscpd/ast-grep — wyłączone polityką w `config.md` (skan on-demand).
|
||||||
|
|
||||||
|
## Import paragonów (Plan 3)
|
||||||
|
- **Zapis operacji przez `OperationModel`** — bez duplikacji walidacji/logiki; `receipt_id` dodany do `allowedFields`. Jedno źródło prawdy zachowane.
|
||||||
|
- **Format kwoty `$fmt`** powtórzony w widokach `receipts/` — świadomie spójny z resztą (YAGNI, jak w Plan 1/2).
|
||||||
|
- **Podpowiedzi kategorii**: priorytet `receipt_category_map` (nauczone) > propozycja AI; NIC nie księgowane bez `confirm()` użytkownika (wymóg twardy spełniony).
|
||||||
|
- **Klucz OpenAI** czytany z `.env` (`OPEN_AI_API`); nie logowany, nie w repo. Model konfigurowalny (`openai.model`).
|
||||||
|
- **OCR paragonów niedeterministyczny**: wiersze rabatu/gęsty druk bywają źle odczytane (waha się między próbami). Świadomie kryte edytowalnymi kwotami/nazwami w przeglądzie (pół-automat). o4-mini najdokładniejszy z testowanych (suma pozycji = total co do grosza), gpt-4o/4.1 gorzej.
|
||||||
|
- **Mechanizm PDF**: konieczne **Responses API + input_file** — chat-completions `file_data` NIE dostarcza treści PDF (model halucynuje). Rozstrzygnięte i wdrożone.
|
||||||
|
- **Do obserwacji**: `normalize()` robi dokładne dopasowanie surowej nazwy — przy rozbieżnych nazwach tej samej rzeczy podpowiedź się nie pojawi; rozbudować gdy za wąska. Koszt tokenów OpenAI per import (o4-mini). Ewent. zapamiętywanie ładnej nazwy w `receipt_category_map`.
|
||||||
|
|
||||||
## Do obserwacji
|
## Do obserwacji
|
||||||
- `portfolioTimeline` liczony w PHP O(daty × operacje) — trywialne przy ręcznych wycenach; przy dużej liczbie wpisów rozważyć agregację SQL.
|
- `portfolioTimeline` liczony w PHP O(daty × operacje) — trywialne przy ręcznych wycenach; przy dużej liczbie wpisów rozważyć agregację SQL.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Tryb: plan
|
||||||
|
Data: 2026-07-12T16:47:19+02:00
|
||||||
|
Projekt: C-visual-studio-code-finansePRO
|
||||||
|
Status indeksu: indexed, moderate, 789 nodes, 1015 edges
|
||||||
|
|
||||||
|
Zapytania: logowanie, sesja, filtr auth, logout, remember.
|
||||||
|
|
||||||
|
Najważniejsze pliki:
|
||||||
|
- `app/Controllers/Auth.php`
|
||||||
|
- `app/Filters/AuthFilter.php`
|
||||||
|
- `app/Views/auth/login.php`
|
||||||
|
- `app/Config/Session.php`
|
||||||
|
- `app/Config/Cookie.php`
|
||||||
|
|
||||||
|
Wniosek: osobne trwałe tokeny per urządzenie odtwarzające istniejącą sesję; bez wydłużania globalnej sesji.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Tryb: post-apply
|
||||||
|
Data: 2026-07-12T17:31:28+02:00
|
||||||
|
Projekt: C-visual-studio-code-finansePRO
|
||||||
|
|
||||||
|
- Indeks moderate odświeżony: 789 nodes, 1015 edges.
|
||||||
|
- `search_graph` nie zwrócił nowych symboli `RememberTokenModel`.
|
||||||
|
- `search_code` zakończył się błędem tworzenia pliku tymczasowego.
|
||||||
|
- Fallback: bezpośredni lint 6 zmienionych plików — wszystkie poprawne.
|
||||||
|
- Zdalna tabela `remember_tokens`: 6 kolumn, 3 indeksy.
|
||||||
|
- PHPUnit: niedostępny, brak `vendor/bin/phpunit`.
|
||||||
|
|
||||||
|
Status: degraded — kod i schemat sprawdzone, brak automatycznego testu runtime.
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
# Tooling Status
|
# Tooling Status
|
||||||
|
|
||||||
**Timestamp:** 2026-07-06T13:10
|
- Timestamp: 2026-07-12T17:31:28+02:00
|
||||||
**Scan mode:** post-apply (plan 20260706-1248-fundament-wydatki-przychody)
|
- Scan mode: post-apply
|
||||||
|
- codebase-memory-mcp: degraded; indeks odświeżony, ale nowe symbole nie zostały zwrócone, a `search_code` zgłosił błąd pliku tymczasowego
|
||||||
## Narzędzia
|
- fallback: `php -l` dla 6 plików — pass
|
||||||
- `codebase-memory-mcp`: enabled, indeks nieodświeżony (ponytail: pełna indeksacja odłożona do `$paul-map-codebase`; kod świeżo dodany, weryfikacja przeszła runtime'owo).
|
- PHPUnit: unavailable (`vendor/bin/phpunit` missing)
|
||||||
- `jscpd`: disabled by policy
|
- jscpd: disabled by policy
|
||||||
- `ast-grep`: disabled by policy
|
- ast-grep: disabled by policy
|
||||||
|
- Raw output: `.paul/codebase/radar/codebase-memory-post-apply.txt`
|
||||||
## Weryfikacja runtime (zamiast skanu statycznego)
|
- Next action: opcjonalny UAT dwóch profili przeglądarki.
|
||||||
- `php spark migrate` / `db:seed` — OK (zdalna baza hostido).
|
|
||||||
- `php spark serve` + curl: login/logout, CRUD kategorii i operacji, filtry, sumy, 3 wykresy dashboardu, blokada usunięcia używanej kategorii, wymuszenie typu operacji przez kategorię — wszystko potwierdzone.
|
|
||||||
|
|
||||||
## Następna akcja
|
|
||||||
`$paul-map-codebase` (pełny indeks codebase-memory-mcp) po ustabilizowaniu modułu, lub przed Planem 2 (inwestycje).
|
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
---
|
||||||
|
plan_id: 20260706-2046-import-paragonow
|
||||||
|
title: Półautomatyczny import paragonów (AI parsowanie + uczone podpowiedzi kategorii)
|
||||||
|
storage: plan-first
|
||||||
|
legacy_phase: null
|
||||||
|
created: 2026-07-06T20:46
|
||||||
|
status: planned
|
||||||
|
type: execute
|
||||||
|
autonomous: false
|
||||||
|
delegation: auto
|
||||||
|
files_modified:
|
||||||
|
- .env
|
||||||
|
- app/Config/Routes.php
|
||||||
|
- app/Database/Migrations/2026-07-06-200001_CreateReceipts.php
|
||||||
|
- app/Database/Migrations/2026-07-06-200002_CreateReceiptItems.php
|
||||||
|
- app/Database/Migrations/2026-07-06-200003_CreateReceiptCategoryMap.php
|
||||||
|
- app/Database/Migrations/2026-07-06-200004_AddReceiptIdToOperations.php
|
||||||
|
- app/Models/ReceiptModel.php
|
||||||
|
- app/Models/ReceiptItemModel.php
|
||||||
|
- app/Models/ReceiptCategoryMapModel.php
|
||||||
|
- app/Libraries/ReceiptParser.php
|
||||||
|
- app/Controllers/Receipts.php
|
||||||
|
- app/Views/receipts/new.php
|
||||||
|
- app/Views/receipts/review.php
|
||||||
|
- app/Views/receipts/index.php
|
||||||
|
- app/Views/receipts/show.php
|
||||||
|
- app/Views/layout/main.php
|
||||||
|
quality_radar: degraded
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## Cel
|
||||||
|
Dodać moduł półautomatycznego importu paragonów: użytkownik wgrywa paragon (jpg/png, pdf lub json),
|
||||||
|
agent AI (OpenAI `gpt-4o-mini`) parsuje pozycje i dopasowuje je do istniejących kategorii wydatków.
|
||||||
|
Na ekranie przeglądu pozycje są **grupowane po kategorii**; użytkownik poprawia przypisania i zatwierdza.
|
||||||
|
Po zatwierdzeniu powstaje **jedna operacja (wydatek) na kategorię** (suma pozycji), a wybory zasilają
|
||||||
|
tabelę uczenia `receipt_category_map` (nazwa pozycji → kategoria) — przy kolejnych paragonach ta sama
|
||||||
|
pozycja dostaje podpowiedź, ale **nigdy nie jest zapisywana automatycznie**.
|
||||||
|
|
||||||
|
## Uzasadnienie
|
||||||
|
Ręczne przepisywanie paragonów pozycja po pozycji jest żmudne. AI + rosnąca pamięć dopasowań skraca
|
||||||
|
import do kilku kliknięć akceptacji, zachowując pełną kontrolę użytkownika (nic nie ląduje w `operations`
|
||||||
|
bez potwierdzenia).
|
||||||
|
|
||||||
|
## Wynik
|
||||||
|
- Nowa zakładka „Import paragonów" (w dropdownie „Finanse bieżące").
|
||||||
|
- 3 nowe tabele (`receipts`, `receipt_items`, `receipt_category_map`) + kolumna `operations.receipt_id`.
|
||||||
|
- Warstwa `ReceiptParser` do komunikacji z OpenAI (bez nowej zależności — CI4 CURLRequest).
|
||||||
|
- Historia importów z podglądem wgranego skanu.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
## Project Docs
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
@.paul/codebase/impact_map.md
|
||||||
|
@.paul/codebase/quality_risks.md
|
||||||
|
|
||||||
|
## Source Files (wzorce do naśladowania)
|
||||||
|
@app/Controllers/Operations.php
|
||||||
|
@app/Models/OperationModel.php
|
||||||
|
@app/Models/CategoryModel.php
|
||||||
|
@app/Config/Routes.php
|
||||||
|
@app/Views/layout/main.php
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<clarifications>
|
||||||
|
Ustalone z użytkownikiem (2026-07-06):
|
||||||
|
1. **Granularość:** pozycje grupowane po kategorii → jedna operacja `expense` na kategorię (suma pozycji).
|
||||||
|
Uczenie działa na poziomie pojedynczej pozycji (`item_name` → `category_id`), nie na całym paragonie.
|
||||||
|
2. **Historia:** pełna — trwałe tabele `receipts` + `receipt_items`, plik skanu trzymany w `writable/uploads/receipts/`.
|
||||||
|
3. **Model AI:** `gpt-4o-mini`, klucz `OPEN_AI_API` już w `.env`; nazwa modelu czytana z `.env` (łatwa podmiana).
|
||||||
|
4. **Formaty:** jpg/png (vision), json (tekst), pdf (OpenAI file input — bez lokalnej rasteryzacji).
|
||||||
|
5. **Podpowiedzi:** priorytet `receipt_category_map` (nauczone) > propozycja AI. Obie tylko sugerują.
|
||||||
|
</clarifications>
|
||||||
|
|
||||||
|
<impact_scan>
|
||||||
|
## Quality Radar
|
||||||
|
|
||||||
|
**Status:** degraded (brak indeksu codebase-memory-mcp; skan runtime + grep; jscpd/ast-grep wyłączone polityką w config.md)
|
||||||
|
**Tools:** grep/Read; codebase-memory-mcp niezaindeksowany (`$paul-map-codebase` odłożone)
|
||||||
|
|
||||||
|
## Affected Areas
|
||||||
|
|
||||||
|
- **Finanse bieżące (operations/categories):** nowy moduł zapisuje do `operations` (`type=expense`, `category_id`) —
|
||||||
|
ta sama tabela i walidacja co ręczne operacje. Kategorie czytane przez `CategoryModel::flatList('expense')`.
|
||||||
|
- **Routing / nawigacja:** nowa grupa tras `receipts/*` w `app/Config/Routes.php` (grupa `auth`); nowy link w `layout/main.php`.
|
||||||
|
- **Storage:** pliki skanów w `writable/uploads/receipts/` (poza `public/`); serwowane przez akcję kontrolera pod filtrem `auth`.
|
||||||
|
- **Konfiguracja:** `.env` — `OPEN_AI_API` (istnieje) + opcjonalny `openai.model`.
|
||||||
|
|
||||||
|
## Duplicate / Hardcoded Risks
|
||||||
|
|
||||||
|
- **Format kwoty `$fmt`** (`number_format(...,2,',',' ').' zł'`) — powtórzony w widokach (świadomie zaakceptowane w quality_risks.md).
|
||||||
|
Handled: w nowych widokach użyć tego samego lokalnego wzorca `$fmt`, nie tworzyć helpera (spójność z resztą, YAGNI).
|
||||||
|
- **Zapis do `operations`** — NIE duplikować logiki walidacji; użyć `OperationModel` (jedno źródło prawdy dla operacji). Handled w Task 5.
|
||||||
|
- **Wzorce zapytań filtered/join** — nie dotyczy; nowe modele są proste (find/insert/upsert).
|
||||||
|
|
||||||
|
## Explicit Deferrals
|
||||||
|
|
||||||
|
- Pełna indeksacja codebase-memory-mcp — odłożone (`$paul-map-codebase`), zgodnie z istniejącym stanem radaru.
|
||||||
|
- Rasteryzacja PDF lokalnie (Imagick/Ghostscript) — niepotrzebna: PDF idzie file-inputem do OpenAI. Odłożone jako niebyt.
|
||||||
|
- Automatyczna kategoryzacja bez potwierdzenia — świadomie POZA zakresem (wymóg: tylko podpowiedź).
|
||||||
|
</impact_scan>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
Brak `.paul/SPECIAL-FLOWS.md` — sekcja skills pominięta.
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## AC-1: Formularz uploadu przyjmuje jpg/png/pdf/json
|
||||||
|
```gherkin
|
||||||
|
Given zalogowany użytkownik na /receipts/new
|
||||||
|
When wgrywa plik .jpg / .png / .pdf / .json o dozwolonym rozmiarze
|
||||||
|
Then plik zostaje zapisany w writable/uploads/receipts/ z bezpieczną, losową nazwą
|
||||||
|
And powstaje rekord w tabeli receipts ze statusem 'parsing' → 'parsed'
|
||||||
|
And niedozwolony typ/rozmiar jest odrzucany z czytelnym komunikatem (bez zapisu)
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-2: AI parsuje paragon i zwraca pozycje z propozycją kategorii
|
||||||
|
```gherkin
|
||||||
|
Given wgrany paragon i lista kategorii wydatków przekazana do modelu
|
||||||
|
When ReceiptParser wywołuje OpenAI gpt-4o-mini z wymuszonym JSON (response_format)
|
||||||
|
Then otrzymujemy merchant, date, total oraz items[] (name, qty, amount, suggested_category)
|
||||||
|
And każda pozycja trafia do receipt_items z suggested_category_id rozwiązanym z odpowiedzi AI
|
||||||
|
And błąd/timeout API ustawia receipt.status='failed' i pokazuje komunikat, nie tworzy operacji
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-3: Nauczone dopasowanie ma priorytet nad AI i tylko podpowiada
|
||||||
|
```gherkin
|
||||||
|
Given pozycja o nazwie występującej wcześniej w receipt_category_map
|
||||||
|
When budowany jest ekran przeglądu
|
||||||
|
Then podpowiedziana kategoria pochodzi z receipt_category_map (priorytet nad AI)
|
||||||
|
And żadna operacja ani mapowanie nie są zapisane dopóki użytkownik nie zatwierdzi
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-4: Ekran przeglądu grupuje pozycje po kategorii i pozwala poprawić
|
||||||
|
```gherkin
|
||||||
|
Given sparsowany paragon na /receipts/(:num)/review
|
||||||
|
When użytkownik widzi pozycje z selectem kategorii (podpowiedź zaznaczona)
|
||||||
|
Then pozycje są zwizualizowane pogrupowane po kategorii z sumą per grupa
|
||||||
|
And użytkownik może zmienić kategorię każdej pozycji lub ją pominąć (bez kategorii = nie księgowana)
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-5: Zatwierdzenie tworzy operacje zbiorczo i uczy mapowań
|
||||||
|
```gherkin
|
||||||
|
Given zweryfikowany paragon z przypisanymi kategoriami
|
||||||
|
When użytkownik klika „Zaksięguj"
|
||||||
|
Then dla każdej kategorii powstaje jedna operacja expense = suma jej pozycji, z receipt_id i opisem (merchant + lista pozycji)
|
||||||
|
And dla każdej pozycji z wybraną kategorią wykonywany jest upsert receipt_category_map (name → category_id)
|
||||||
|
And receipt.status='imported'; ponowne księgowanie tego samego paragonu jest zablokowane
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-6: Historia i podgląd skanu
|
||||||
|
```gherkin
|
||||||
|
Given zaimportowane/wgrane paragony
|
||||||
|
When użytkownik otwiera /receipts
|
||||||
|
Then widzi listę (data, sprzedawca, suma, status, liczba operacji) z akcjami podgląd/usuń
|
||||||
|
And podgląd skanu serwowany jest tylko dla zalogowanego (filtr auth), plik spoza public/
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Migracje — receipts, receipt_items, receipt_category_map, operations.receipt_id</name>
|
||||||
|
<files>app/Database/Migrations/2026-07-06-200001_CreateReceipts.php, app/Database/Migrations/2026-07-06-200002_CreateReceiptItems.php, app/Database/Migrations/2026-07-06-200003_CreateReceiptCategoryMap.php, app/Database/Migrations/2026-07-06-200004_AddReceiptIdToOperations.php</files>
|
||||||
|
<action>
|
||||||
|
Wzoruj się na istniejących migracjach (2026-07-06-140002 itd.).
|
||||||
|
- `receipts`: id, file_path VARCHAR(255), original_name VARCHAR(255), mime VARCHAR(100),
|
||||||
|
merchant VARCHAR(190) NULL, receipt_date DATE NULL, total DECIMAL(12,2) NULL,
|
||||||
|
status ENUM('parsing','parsed','failed','imported') DEFAULT 'parsing',
|
||||||
|
raw_json LONGTEXT NULL, error_msg VARCHAR(255) NULL, created_at, updated_at.
|
||||||
|
- `receipt_items`: id, receipt_id INT UNSIGNED, name VARCHAR(255), qty DECIMAL(10,3) NULL,
|
||||||
|
amount DECIMAL(12,2), suggested_category_id INT UNSIGNED NULL, chosen_category_id INT UNSIGNED NULL,
|
||||||
|
created_at, updated_at. FK receipt_id → receipts.id ON DELETE CASCADE.
|
||||||
|
FK suggested/chosen_category_id → categories.id ON DELETE SET NULL.
|
||||||
|
- `receipt_category_map`: id, item_name VARCHAR(255) (UNIQUE), category_id INT UNSIGNED,
|
||||||
|
updated_at. FK category_id → categories.id ON DELETE CASCADE. (item_name znormalizowane — patrz Task 3.)
|
||||||
|
- `operations`: dodać kolumnę receipt_id INT UNSIGNED NULL po category_id; FK → receipts.id ON DELETE SET NULL.
|
||||||
|
down() usuwa FK i kolumnę.
|
||||||
|
</action>
|
||||||
|
<verify>php spark migrate; sprawdź 4 nowe struktury (SHOW TABLES / DESCRIBE operations)</verify>
|
||||||
|
<done>AC-1, AC-5 (schema); zgodne z impact_map (jedno źródło prawdy operacji)</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Modele — ReceiptModel, ReceiptItemModel, ReceiptCategoryMapModel</name>
|
||||||
|
<files>app/Models/ReceiptModel.php, app/Models/ReceiptItemModel.php, app/Models/ReceiptCategoryMapModel.php</files>
|
||||||
|
<action>
|
||||||
|
Wzoruj się na OperationModel/CategoryModel (returnType array, useTimestamps true, allowedFields).
|
||||||
|
- ReceiptModel: allowedFields dla wszystkich pól z Task 1; walidacja mime/status.
|
||||||
|
- ReceiptItemModel: allowedFields; metoda `forReceipt(int $receiptId): array`.
|
||||||
|
- ReceiptCategoryMapModel:
|
||||||
|
* `normalize(string $name): string` — trim, mb_strtolower, redukcja wielokrotnych spacji
|
||||||
|
(opcjonalnie obcięcie kończówek liczbowych/gramatur — MINIMALNIE, patrz ponytail).
|
||||||
|
* `suggest(string $name): ?int` — zwraca category_id dla znormalizowanej nazwy lub null.
|
||||||
|
* `remember(string $name, int $categoryId): void` — upsert po znormalizowanym item_name
|
||||||
|
(INSERT ... ON DUPLICATE KEY UPDATE lub find+update).
|
||||||
|
</action>
|
||||||
|
<verify>Ręczny test w tinker/kontrolerze: remember('Mleko 3.2%', 5) → suggest(' mleko 3.2% ') === 5</verify>
|
||||||
|
<done>AC-2, AC-3</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: ReceiptParser — warstwa OpenAI (jpg/png vision, json tekst, pdf file input)</name>
|
||||||
|
<files>app/Libraries/ReceiptParser.php, .env</files>
|
||||||
|
<action>
|
||||||
|
Klasa `App\Libraries\ReceiptParser` bez nowej zależności — użyj `\Config\Services::curlrequest()`.
|
||||||
|
- Klucz: `env('OPEN_AI_API')`. Model: `env('openai.model', 'gpt-4o-mini')` — dopisz zakomentowany
|
||||||
|
`# openai.model = gpt-4o-mini` do `.env` (klucz OPEN_AI_API już istnieje, nie ruszać wartości).
|
||||||
|
- Metoda `parse(string $filePath, string $mime, array $categories): array` zwraca:
|
||||||
|
['merchant'=>?string,'date'=>?string,'total'=>?float,'items'=>[['name','qty','amount','category'=>?string], ...]].
|
||||||
|
- Endpoint: POST https://api.openai.com/v1/chat/completions, `response_format` = json_schema (lub json_object)
|
||||||
|
wymuszający powyższą strukturę. W system/prompt przekaż listę nazw kategorii wydatków, aby AI mapowało
|
||||||
|
`category` do jednej z nich (dokładna nazwa albo null).
|
||||||
|
- Wejście wg mime:
|
||||||
|
* image/* → content image_url z data URL base64 (`data:{mime};base64,...`).
|
||||||
|
* application/pdf → OpenAI file input (typ input_file / base64 file) — bez lokalnej rasteryzacji.
|
||||||
|
* application/json (lub .json) → wczytaj tekst pliku, przekaż jako treść do interpretacji.
|
||||||
|
- Timeout ~60s; przy błędzie HTTP/parsowania rzuć wyjątek z czytelnym komunikatem (łapany w kontrolerze).
|
||||||
|
- `category` z odpowiedzi rozwiązywane do category_id po nazwie w kontrolerze (Task 4), nie tutaj.
|
||||||
|
// ponytail: bez SDK — jeden POST CURLRequest; jeśli pojawi się drugi endpoint, wtedy refaktor.
|
||||||
|
</action>
|
||||||
|
<verify>Testowy jpg paragonu → parse() zwraca ≥1 pozycję z kwotą; błędny klucz → wyjątek z komunikatem</verify>
|
||||||
|
<done>AC-2</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 4: Kontroler Receipts + trasy — upload, parse, review</name>
|
||||||
|
<files>app/Controllers/Receipts.php, app/Config/Routes.php</files>
|
||||||
|
<action>
|
||||||
|
Kontroler wzorowany na Operations.php. Trasy w grupie `auth`:
|
||||||
|
GET receipts → index
|
||||||
|
GET receipts/new → new
|
||||||
|
POST receipts → create
|
||||||
|
GET receipts/(:num)/review → review/$1
|
||||||
|
POST receipts/(:num)/confirm → confirm/$1 (Task 5)
|
||||||
|
GET receipts/(:num) → show
|
||||||
|
GET receipts/(:num)/file → file/$1 (serwowanie skanu, auth)
|
||||||
|
GET receipts/(:num)/delete → delete/$1
|
||||||
|
- create(): walidacja uploadu (ext in jpg,jpeg,png,pdf,json; max_size np. 10240 KB; is_image lub mime whitelist).
|
||||||
|
Zapis pliku przez `$file->store('receipts', $file->getRandomName())` do writable/uploads/receipts/.
|
||||||
|
Utwórz receipt (status 'parsing'). Wywołaj ReceiptParser::parse() z `CategoryModel::flatList('expense')`.
|
||||||
|
Dla każdej pozycji: rozwiąż `category` (AI) → suggested_category_id po nazwie kategorii;
|
||||||
|
NADPISZ podpowiedź wynikiem `ReceiptCategoryMapModel::suggest(name)` gdy istnieje (priorytet nauczonego).
|
||||||
|
Zapisz receipt_items, raw_json, merchant/date/total, status 'parsed'; redirect → review.
|
||||||
|
Przy wyjątku parsera: status 'failed', error_msg; redirect → show z komunikatem błędu.
|
||||||
|
- review(): pobierz receipt + items + `CategoryModel::flatList('expense')`; przekaż do widoku.
|
||||||
|
Blokuj review gdy status='imported' (redirect → show).
|
||||||
|
- file(): odczyt pliku spoza public i zwrot z właściwym Content-Type (tylko pod filtrem auth).
|
||||||
|
- index()/show()/delete(): lista, podgląd, usunięcie (delete kasuje też plik z dysku; CASCADE czyści items).
|
||||||
|
</action>
|
||||||
|
<verify>Upload testowego pliku → rekord receipt 'parsed' + wiersze receipt_items; /receipts/(:num)/review renderuje</verify>
|
||||||
|
<done>AC-1, AC-2, AC-3, AC-6</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 5: Zatwierdzenie — grupowanie po kategorii, zapis operacji, uczenie mapowań</name>
|
||||||
|
<files>app/Controllers/Receipts.php</files>
|
||||||
|
<action>
|
||||||
|
confirm(int $id):
|
||||||
|
- Pobierz z POST tablicę chosen_category_id per receipt_item (id → category_id, puste = pomiń pozycję).
|
||||||
|
- Zapisz chosen_category_id do receipt_items.
|
||||||
|
- Zablokuj podwójne księgowanie: jeśli receipt.status='imported' → redirect z komunikatem.
|
||||||
|
- Grupuj pozycje z wybraną kategorią po category_id; dla każdej grupy jedna operacja przez `OperationModel`:
|
||||||
|
date = receipt_date ?? today; amount = SUMA amount grupy; type='expense'; category_id; receipt_id=$id;
|
||||||
|
description = merchant + ': ' + lista nazw pozycji (skrócona). NIE duplikować walidacji — użyć OperationModel::insert.
|
||||||
|
- Dla każdej pozycji z wybraną kategorią: `ReceiptCategoryMapModel::remember(name, category_id)` (uczenie).
|
||||||
|
- status receipt = 'imported'; redirect → show z liczbą utworzonych operacji.
|
||||||
|
// ponytail: brak transakcji rozproszonej; jedno przejście w transakcji DB ($db->transStart/Complete) dla spójności.
|
||||||
|
</action>
|
||||||
|
<verify>Paragon z 3 kategoriami → 3 operacje (sumy), wpisy w receipt_category_map; ponowny confirm zablokowany</verify>
|
||||||
|
<done>AC-4, AC-5</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 6: Widoki + nawigacja</name>
|
||||||
|
<files>app/Views/receipts/new.php, app/Views/receipts/review.php, app/Views/receipts/index.php, app/Views/receipts/show.php, app/Views/layout/main.php</files>
|
||||||
|
<action>
|
||||||
|
Rozszerz `layout/main.php` (extend, sekcja content — jak inne widoki). Użyj lokalnego wzorca `$fmt` do kwot (spójnie z resztą).
|
||||||
|
- new.php: form multipart z `<input type="file" accept=".jpg,.jpeg,.png,.pdf,.application/json,image/*,application/pdf">`,
|
||||||
|
krótka instrukcja, przycisk „Wgraj i przetwórz". Pokaż flash error (walidacja).
|
||||||
|
- review.php: nagłówek (merchant, data, suma z AI). Lista pozycji: nazwa, kwota, `<select name="chosen_category_id[ID]">`
|
||||||
|
z `CategoryModel::flatList('expense')`, zaznaczona podpowiedź (chosen ?? suggested). Opcja pusta = „— pomiń —".
|
||||||
|
Sekcja podglądu „grupowanie po kategorii" z sumą per kategoria (JS liczy na żywo z selectów — vanilla, bez zależności).
|
||||||
|
Form POST → receipts/(:num)/confirm, przycisk „Zaksięguj".
|
||||||
|
- index.php: tabela historii (data, sprzedawca, suma, status badge, akcje: podgląd/usuń) + przycisk „Nowy import".
|
||||||
|
- show.php: dane paragonu, status, link/obraz skanu (`receipts/(:num)/file`), lista utworzonych operacji (jeśli imported),
|
||||||
|
komunikat błędu (jeśli failed) z możliwością ponownego przetworzenia lub usunięcia.
|
||||||
|
- main.php: w dropdownie „Finanse bieżące" dodać `<li><a class="dropdown-item" href="<?= site_url('receipts') ?>">Import paragonów</a></li>`.
|
||||||
|
</action>
|
||||||
|
<verify>Pełny przepływ w przeglądarce: upload → review → confirm → operacje widoczne w /operations; nav działa</verify>
|
||||||
|
<done>AC-1, AC-4, AC-5, AC-6</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
## Do Not Change
|
||||||
|
- Istniejąca logika `OperationModel` / `CategoryModel` / kontrolera Operations — moduł tylko konsumuje.
|
||||||
|
- Wartość `OPEN_AI_API` w `.env` (tylko odczyt; ewentualnie dopisać `openai.model`).
|
||||||
|
- Schemat i logika modułu inwestycji (`inv_*`) — brak styku.
|
||||||
|
- Auth/flow logowania.
|
||||||
|
|
||||||
|
## Scope Limits
|
||||||
|
- Brak automatycznego księgowania bez potwierdzenia użytkownika (wymóg twardy).
|
||||||
|
- Brak lokalnej rasteryzacji PDF (idzie do OpenAI file input).
|
||||||
|
- Brak edycji kwot/pozycji poza wyborem kategorii i pominięciem pozycji (MVP; edycja kwot — ewentualnie później).
|
||||||
|
- Brak kolejkowania/async — parsowanie synchroniczne w request (paragon = mały wolumen).
|
||||||
|
- Brak pełnej indeksacji codebase-memory-mcp (odłożone).
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- [ ] `php spark migrate` zakłada 3 tabele + kolumnę `operations.receipt_id` (Task 1).
|
||||||
|
- [ ] Upload jpg/png/pdf/json działa; niedozwolony typ odrzucony (AC-1).
|
||||||
|
- [ ] ReceiptParser zwraca pozycje z gpt-4o-mini; błąd API → status 'failed', brak operacji (AC-2).
|
||||||
|
- [ ] Nauczone mapowanie ma priorytet nad AI i tylko podpowiada (AC-3).
|
||||||
|
- [ ] Review grupuje po kategorii z sumami; edycja/pominięcie działa (AC-4).
|
||||||
|
- [ ] Confirm tworzy 1 operację/kategoria (suma) + uczy mapowań; podwójne księgowanie zablokowane (AC-5).
|
||||||
|
- [ ] Historia + podgląd skanu tylko dla zalogowanego (AC-6).
|
||||||
|
- [ ] Quality Radar: format kwoty `$fmt` spójny; zapis operacji tylko przez OperationModel (bez duplikacji walidacji).
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- [ ] Wszystkie AC-1..AC-6 przechodzą (weryfikacja manualna w przeglądarce).
|
||||||
|
- [ ] Brak drugiego źródła prawdy dla operacji (użyty OperationModel).
|
||||||
|
- [ ] `.env`: klucz odczytywany, model konfigurowalny; sekret nie trafia do repo/logów.
|
||||||
|
- [ ] Impact map / quality risks zaktualizowane w UNIFY.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
SUMMARY.md path: `.paul/plans/20260706-2046-import-paragonow/SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
plan_id: 20260706-2046-import-paragonow
|
||||||
|
title: Półautomatyczny import paragonów (AI parsowanie + uczone podpowiedzi kategorii)
|
||||||
|
completed: 2026-07-06T22:30
|
||||||
|
storage: plan-first
|
||||||
|
quality_radar: degraded
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Półautomatyczny import paragonów
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Umożliwić wgranie paragonu (jpg/png, pdf, json), automatyczne odczytanie pozycji przez AI (OpenAI),
|
||||||
|
dopasowanie ich do kategorii wydatków z uczonymi podpowiedziami, przegląd/korektę przez użytkownika
|
||||||
|
i zaksięgowanie jako operacje pogrupowane po kategorii (1 operacja `expense` / kategoria). Nic nie trafia
|
||||||
|
do `operations` bez zatwierdzenia; podpowiedzi kategorii nigdy nie księgują się automatycznie.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
| Area | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Baza | 3 tabele: `receipts`, `receipt_items` (+ `display_name`), `receipt_category_map`; kolumna `operations.receipt_id` (FK SET NULL) |
|
||||||
|
| Modele | `ReceiptModel` (`withCounts`), `ReceiptItemModel` (`forReceipt`), `ReceiptCategoryMapModel` (`normalize`/`suggest`/`remember`) |
|
||||||
|
| Parser AI | `App\Libraries\ReceiptParser` — OpenAI **Responses API** (`/v1/responses` + `input_file`), model **o4-mini**, prompt świadomy kolumn paragonu, dwie nazwy (`name` surowa + `display` ładna) |
|
||||||
|
| Kontroler | `Receipts` (upload→parse→review→confirm→history→file→delete) + trasy `receipts/*` |
|
||||||
|
| Widoki | `receipts/{new,review,index,show}.php`; przegląd z edytowalnymi nazwami/kwotami i podglądem grupowania na żywo; link „Import paragonów" w nawigacji |
|
||||||
|
| Kategorie | `CategoryModel::tree()` rozszerzony o `path_label` („Nadrzędna → Podrzędna") — jednoznaczność w podglądzie |
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `app/Database/Migrations/2026-07-06-2000{01..05}_*.php` - schemat modułu + `display_name`
|
||||||
|
- `app/Models/{ReceiptModel,ReceiptItemModel,ReceiptCategoryMapModel}.php` - nowe modele
|
||||||
|
- `app/Models/OperationModel.php` - `allowedFields` += `receipt_id`
|
||||||
|
- `app/Models/CategoryModel.php` - `path_label` w `tree()`
|
||||||
|
- `app/Libraries/ReceiptParser.php` - warstwa OpenAI (Responses API, o4-mini, `name`+`display`)
|
||||||
|
- `app/Controllers/Receipts.php` - pełny przepływ, walidacja bez finfo, transakcja, uczenie na surowej nazwie
|
||||||
|
- `app/Config/Routes.php` - grupa `receipts/*`
|
||||||
|
- `app/Views/receipts/*.php`, `app/Views/layout/main.php` - UI + nawigacja
|
||||||
|
- `.env` - komentarz `openai.model` (domyślnie o4-mini)
|
||||||
|
|
||||||
|
## Acceptance Criteria Results
|
||||||
|
|
||||||
|
| Criterion | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| AC-1 Upload jpg/png/pdf/json | Pass | `/receipts/new` HTTP 200; walidacja rozszerzeń bez finfo; plik w `writable/uploads/receipts/` |
|
||||||
|
| AC-2 AI parsuje pozycje + kategorie | Pass | Responses API + o4-mini odczytał realny PDF (15 poz., suma = total 68,74); błąd API → status `failed` + `error_msg` |
|
||||||
|
| AC-3 Nauczone dopasowanie ma priorytet i tylko podpowiada | Pass | `suggest()` (surowa nazwa) > propozycja AI; brak zapisu do `operations` przed `confirm` |
|
||||||
|
| AC-4 Przegląd grupuje po kategorii + edycja | Pass | Edytowalne nazwa/kwota, podgląd sum na żywo, `path_label` rozróżnia kategorie |
|
||||||
|
| AC-5 Zatwierdzenie: 1 operacja/kategoria + uczenie | Pass | Grupowanie sum → `OperationModel::insert` z `receipt_id`; `remember(surowa_nazwa, kategoria)`; blokada podwójnego księgowania; transakcja DB |
|
||||||
|
| AC-6 Historia + podgląd skanu | Pass | `/receipts` HTTP 200; `Receipts::file` serwuje spoza `public/` pod filtrem `auth` |
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
| Check | Result | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `php -l` (wszystkie pliki) | Pass | Zero błędów składni |
|
||||||
|
| Self-check `normalize`+grupowanie | Pass | assert OK |
|
||||||
|
| Migracja produkcyjna | Pass | DDL 3 tabel + `receipt_id` + `display_name` zastosowane na zdalnej bazie; wpisy w `migrations` (batch 3 i 4) |
|
||||||
|
| UAT `/receipts`, `/receipts/new` | Pass | HTTP 200 po zalogowaniu (curl) |
|
||||||
|
| UAT realny paragon (PDF) | Pass | Użytkownik potwierdził: pozycje/kwoty poprawne (poza wierszem rabatu — korekta ręczna), ładne nazwy OK |
|
||||||
|
|
||||||
|
## Quality Radar Results
|
||||||
|
|
||||||
|
**Status:** degraded (brak indeksu codebase-memory-mcp; jscpd/ast-grep wyłączone polityką)
|
||||||
|
|
||||||
|
- New risks: OCR paragonów niedeterministyczny na wierszach rabatu/gęstym druku — świadomie kryty edytowalnymi kwotami (pół-automat). Koszt tokenów OpenAI per import (o4-mini rozumujący).
|
||||||
|
- Resolved risks: zapis operacji tylko przez `OperationModel` (bez duplikacji walidacji); klucz OpenAI z `.env`, nie logowany.
|
||||||
|
- Deferred risks: pełna indeksacja codebase-memory-mcp (`$paul-map-codebase`); normalizacja nazw = dokładne dopasowanie (rozbudować gdy za wąska).
|
||||||
|
- Raw outputs: `.paul/codebase/{impact_map,quality_risks,tooling_status}.md`
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
- **Mechanizm AI zmieniony w trakcie**: chat-completions `file_data` NIE dostarczał treści PDF (model halucynował) → przełączono na **Responses API `/v1/responses` + `input_file`**.
|
||||||
|
- **Model**: plan zakładał gpt-4o-mini; okazał się za słaby na gęsty druk → domyślnie **o4-mini** (rozumujący, najlepszy OCR + rabaty, tańszy niż gpt-4o). Testy: o4-mini suma pozycji = total co do grosza; gpt-4o/4.1 gorzej.
|
||||||
|
- **Bez finfo**: hostido ma wyłączone `fileinfo` → własna nazwa pliku + MIME z rozszerzenia, walidacja bez `ext_in`.
|
||||||
|
- **Dwie nazwy pozycji** (dodane na życzenie): `name` (surowa, klucz mapowania) + `display_name` (ładna, do wyświetlania/opisu).
|
||||||
|
- **PDF = obraz** (brak warstwy tekstowej) → OCR modelu, nie ekstrakcja tekstu.
|
||||||
|
- Migracje stosowane ręcznie na zdalnej bazie (deploy hostido nie uruchamia `spark migrate`).
|
||||||
|
|
||||||
|
## Key Decisions / Patterns
|
||||||
|
|
||||||
|
- Przy paragonach liczy się **rozumowanie modelu, nie rozmiar** (o4-mini > gpt-4o/4.1).
|
||||||
|
- **Pół-automat**: AI proponuje, człowiek weryfikuje/koryguje kwoty i nazwy przed księgowaniem.
|
||||||
|
- **Uczenie na surowej nazwie z paragonu** (stabilny klucz między wizytami), wyświetlanie po ładnej.
|
||||||
|
- Jedno źródło prawdy operacji — `OperationModel`; `receipt_id` łączy operacje z paragonem (FK SET NULL).
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- Opcjonalnie: zapamiętywanie ładnej nazwy w `receipt_category_map` (spójny display przy powtórkach).
|
||||||
|
- Rozważyć limit/licznik kosztów OpenAI, jeśli wolumen wzrośnie.
|
||||||
|
- `$paul-map-codebase` dla pełnego indeksu przed kolejnym większym planem.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
---
|
||||||
|
plan_id: 20260712-1647-zapamietywanie-logowania
|
||||||
|
title: Zapamiętywanie logowania przez 30 dni
|
||||||
|
storage: plan-first
|
||||||
|
legacy_phase: null
|
||||||
|
created: 2026-07-12T16:47:19+02:00
|
||||||
|
status: planned
|
||||||
|
type: execute
|
||||||
|
autonomous: true
|
||||||
|
delegation: auto
|
||||||
|
files_modified:
|
||||||
|
- app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php
|
||||||
|
- app/Models/RememberTokenModel.php
|
||||||
|
- app/Controllers/Auth.php
|
||||||
|
- app/Filters/AuthFilter.php
|
||||||
|
- app/Views/auth/login.php
|
||||||
|
- tests/unit/RememberTokenModelTest.php
|
||||||
|
quality_radar: ok
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## Cel
|
||||||
|
Umożliwić użytkownikowi zaznaczenie opcji „Zapamiętaj mnie” podczas logowania i utrzymać dostęp przez 30 dni niezależnie na wielu komputerach.
|
||||||
|
|
||||||
|
## Powód
|
||||||
|
Obecna sesja wygasa po 2 godzinach i nie przeżywa zamknięcia przeglądarki. Osobny token dla każdego urządzenia zachowuje równoległe logowania bez współdzielenia jednego identyfikatora sesji.
|
||||||
|
|
||||||
|
## Wynik
|
||||||
|
Checkbox w formularzu, trwałe tokeny przechowywane w bazie wyłącznie jako skróty, automatyczne odtworzenie sesji oraz unieważnienie tokenu bieżącego urządzenia przy wylogowaniu.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
## Dokumenty projektu
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
@.paul/config.md
|
||||||
|
@.paul/codebase/impact_map.md
|
||||||
|
@.paul/codebase/quality_risks.md
|
||||||
|
|
||||||
|
## Pliki źródłowe
|
||||||
|
@app/Controllers/Auth.php
|
||||||
|
@app/Filters/AuthFilter.php
|
||||||
|
@app/Views/auth/login.php
|
||||||
|
@app/Config/Routes.php
|
||||||
|
@app/Config/Cookie.php
|
||||||
|
@app/Config/Session.php
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<clarifications>
|
||||||
|
- „Wiele komputerów jednocześnie” oznacza osobny aktywny token na każdym urządzeniu; kolejne logowanie nie usuwa wcześniejszych tokenów.
|
||||||
|
- Wylogowanie usuwa token tylko z bieżącego urządzenia. Globalne „wyloguj wszędzie” jest poza zakresem.
|
||||||
|
</clarifications>
|
||||||
|
|
||||||
|
<impact_scan>
|
||||||
|
## Quality Radar
|
||||||
|
|
||||||
|
**Status:** ok
|
||||||
|
**Narzędzia:** codebase-memory-mcp 0.6.1; jscpd i ast-grep wyłączone polityką projektu.
|
||||||
|
|
||||||
|
## Dotknięte obszary
|
||||||
|
- Logowanie: `Auth::login`, `Auth::logout`, `app/Views/auth/login.php`.
|
||||||
|
- Ochrona tras: `AuthFilter::before` odtwarza zwykłą sesję po poprawnym tokenie.
|
||||||
|
- Dane: nowa tabela tokenów z wieloma rekordami; konto nadal pochodzi z `.env`.
|
||||||
|
- Wdrożenie: nowa migracja wymaga ręcznego uruchomienia na hostido.
|
||||||
|
|
||||||
|
## Ryzyka duplikacji / wartości hardcoded
|
||||||
|
- Czas 30 dni ma jedno źródło prawdy w `RememberTokenModel`.
|
||||||
|
- Token cookie nie może być zapisany jawnie w bazie; tabela przechowuje selektor i hash walidatora.
|
||||||
|
- Nie wydłużać globalnie `Session::$expiration`, bo nie zapewnia niezależnych urządzeń.
|
||||||
|
|
||||||
|
## Jawne odroczenia
|
||||||
|
- Panel urządzeń i „wyloguj wszędzie” — dodać dopiero na żądanie.
|
||||||
|
- Harmonogram czyszczenia — przy tej skali wygasłe tokeny wystarczy usuwać podczas użycia mechanizmu.
|
||||||
|
</impact_scan>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## AC-1: Logowanie bez zapamiętania
|
||||||
|
```gherkin
|
||||||
|
Given użytkownik nie zaznaczył „Zapamiętaj mnie”
|
||||||
|
When poda poprawny email i hasło
|
||||||
|
Then otrzymuje wyłącznie obecną sesję bez trwałego cookie logowania
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-2: Logowanie zapamiętane przez 30 dni
|
||||||
|
```gherkin
|
||||||
|
Given użytkownik zaznaczył „Zapamiętaj mnie”
|
||||||
|
When poda poprawny email i hasło
|
||||||
|
Then aplikacja zapisuje bezpieczny token i cookie ważne 30 dni
|
||||||
|
And po utracie zwykłej sesji poprawny token odtwarza sesję i wpuszcza na chronioną trasę
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-3: Wiele urządzeń
|
||||||
|
```gherkin
|
||||||
|
Given użytkownik zaznaczył opcję na dwóch urządzeniach
|
||||||
|
When oba urządzenia otwierają chronioną trasę bez zwykłej sesji
|
||||||
|
Then oba niezależne tokeny pozostają poprawne
|
||||||
|
And wylogowanie na jednym urządzeniu nie unieważnia drugiego
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-4: Odrzucenie nieważnego tokenu
|
||||||
|
```gherkin
|
||||||
|
Given cookie jest zmienione, wygasłe lub nie ma odpowiadającego rekordu
|
||||||
|
When użytkownik otwiera chronioną trasę
|
||||||
|
Then cookie i wadliwy rekord są usuwane w możliwym zakresie
|
||||||
|
And użytkownik trafia na `/login`
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Dodać bezpieczne tokeny per urządzenie</name>
|
||||||
|
<files>app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php, app/Models/RememberTokenModel.php</files>
|
||||||
|
<action>Utworzyć tabelę z unikalnym selektorem, hashem walidatora, `expires_at` i timestampami. W modelu umieścić stałą 30 dni oraz operacje wystawienia tokenu przez `random_bytes`, weryfikacji przez `hash_equals`, usunięcia bieżącego tokenu i wygasłych rekordów. Każde logowanie dopisuje rekord.</action>
|
||||||
|
<verify>php -l app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php; php -l app/Models/RememberTokenModel.php</verify>
|
||||||
|
<done>AC-2, AC-3 i AC-4 mają trwały mechanizm danych bez jawnych sekretów w bazie.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Podłączyć checkbox, odtworzenie sesji i wylogowanie</name>
|
||||||
|
<files>app/Controllers/Auth.php, app/Filters/AuthFilter.php, app/Views/auth/login.php</files>
|
||||||
|
<action>Dodać checkbox `remember`. Po poprawnym logowaniu tworzyć cookie tylko po zaznaczeniu. W filtrze zachować obecną ścieżkę sesji, a przy jej braku zweryfikować cookie i ustawić `logged_in`/`user_email`. Cookie: HttpOnly, SameSite=Lax, 30 dni, Secure w produkcji. `logout()` usuwa token bieżącego cookie, cookie i sesję.</action>
|
||||||
|
<verify>php -l app/Controllers/Auth.php; php -l app/Filters/AuthFilter.php; php -l app/Views/auth/login.php</verify>
|
||||||
|
<done>AC-1..AC-4 są obsłużone w przepływie HTTP.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Sprawdzić bezpieczeństwo i równoległe urządzenia</name>
|
||||||
|
<files>tests/unit/RememberTokenModelTest.php</files>
|
||||||
|
<action>Dodać jeden mały test obejmujący dwa jednocześnie poprawne tokeny, odrzucenie zmienionego walidatora oraz unieważnienie jednego bez wpływu na drugi. Wykonać UAT w dwóch profilach przeglądarki.</action>
|
||||||
|
<verify>vendor/bin/phpunit tests/unit/RememberTokenModelTest.php; ręcznie: dwa profile przeglądarki zgodnie z AC-1..AC-4</verify>
|
||||||
|
<done>AC-1..AC-4 mają automatyczną kontrolę tokenów i potwierdzenie przepływu przeglądarkowego.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
## Nie zmieniać
|
||||||
|
- Hasło i email nadal pochodzą z `.env`; nie tworzyć systemu wielu kont.
|
||||||
|
- Nie zmieniać czasu zwykłej sesji ani chronionych tras.
|
||||||
|
- Nie dotykać modułów finansów, inwestycji i paragonów.
|
||||||
|
|
||||||
|
## Ograniczenia zakresu
|
||||||
|
- Bez nowej zależności, panelu urządzeń, resetu hasła i „wyloguj wszędzie”.
|
||||||
|
- Bez surowego tokenu lub hasła w bazie i logach.
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- [ ] `php -l` przechodzi dla zmienionych plików PHP.
|
||||||
|
- [ ] `vendor/bin/phpunit tests/unit/RememberTokenModelTest.php` przechodzi w środowisku z `vendor/`.
|
||||||
|
- [ ] UAT potwierdza zwykłe logowanie, 30-dniowe cookie, dwa profile, wylogowanie jednego profilu i odrzucenie zmienionego cookie.
|
||||||
|
- [ ] Cookie ma HttpOnly, SameSite=Lax, właściwy termin i Secure na produkcji.
|
||||||
|
- [ ] Migracja jest zastosowana ręcznie na zdalnej bazie przed wdrożeniem kodu.
|
||||||
|
- [ ] Ryzyka Quality Radar są obsłużone lub jawnie odroczone.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- [ ] Wszystkie AC przechodzą.
|
||||||
|
- [ ] Weryfikacja jest kompletna.
|
||||||
|
- [ ] Dwa urządzenia pozostają zalogowane niezależnie przez maksymalnie 30 dni.
|
||||||
|
- [ ] Baza nie zawiera surowych tokenów.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
SUMMARY.md path: `.paul/plans/20260712-1647-zapamietywanie-logowania/SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
plan_id: 20260712-1647-zapamietywanie-logowania
|
||||||
|
title: Zapamiętywanie logowania przez 30 dni
|
||||||
|
completed: 2026-07-12T17:31:28+02:00
|
||||||
|
storage: plan-first
|
||||||
|
quality_radar: degraded
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Zapamiętywanie logowania przez 30 dni
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Dodano opcjonalne logowanie zapamiętane przez 30 dni z niezależnym tokenem dla każdego komputera.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
| Area | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Dane | Tabela `remember_tokens` z selektorem, hashem walidatora i terminem ważności; tabela utworzona również na zdalnej bazie. |
|
||||||
|
| Logowanie | Checkbox `remember` wystawia 30-dniowe cookie tylko po zaznaczeniu. |
|
||||||
|
| Ochrona tras | `AuthFilter` odtwarza zwykłą sesję z poprawnego tokenu. |
|
||||||
|
| Wylogowanie | Usuwa token wyłącznie bieżącego urządzenia, cookie i sesję. |
|
||||||
|
| Test | Dodano jeden test dwóch tokenów, manipulacji walidatorem i selektywnego unieważnienia. |
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `app/Database/Migrations/2026-07-12-160001_CreateRememberTokens.php` — schemat tokenów.
|
||||||
|
- `app/Models/RememberTokenModel.php` — wystawianie, weryfikacja i usuwanie tokenów.
|
||||||
|
- `app/Controllers/Auth.php` — wystawianie cookie i selektywne wylogowanie.
|
||||||
|
- `app/Filters/AuthFilter.php` — odtworzenie sesji.
|
||||||
|
- `app/Views/auth/login.php` — checkbox „Zapamiętaj mnie”.
|
||||||
|
- `tests/unit/RememberTokenModelTest.php` — kontrola logiki wielu urządzeń.
|
||||||
|
|
||||||
|
## Acceptance Criteria Results
|
||||||
|
|
||||||
|
| Criterion | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| AC-1 | Pass | Bez checkboxa `Auth::login` nie wystawia trwałego cookie. |
|
||||||
|
| AC-2 | Partial | Kod, cookie 30 dni i zdalna tabela są gotowe; brak potwierdzonego końcowego UAT po migracji. |
|
||||||
|
| AC-3 | Partial | Model dopisuje niezależne rekordy, a logout usuwa bieżący selektor; test nie został uruchomiony lokalnie. |
|
||||||
|
| AC-4 | Partial | Filtr odrzuca zły/wygasły token i usuwa cookie; test nie został uruchomiony lokalnie. |
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
| Check | Result | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `php -l` dla 6 plików | Pass | Brak błędów składni. |
|
||||||
|
| `vendor/bin/phpunit tests/unit/RememberTokenModelTest.php` | Skipped | Lokalnie brak `vendor/` i `vendor/bin/phpunit`. |
|
||||||
|
| Zdalny `remember_tokens` | Pass | Potwierdzono 6 kolumn i 3 indeksy. |
|
||||||
|
| UAT w dwóch profilach | Skipped | Niepotwierdzony przez użytkownika po aktualizacji bazy. |
|
||||||
|
|
||||||
|
## Quality Radar Results
|
||||||
|
|
||||||
|
**Status:** degraded
|
||||||
|
|
||||||
|
- New risks: wdrożenie kodu przed migracją powoduje błąd DB przy użyciu tokenu.
|
||||||
|
- Resolved risks: zdalna tabela została utworzona; surowy walidator nie trafia do bazy.
|
||||||
|
- Deferred risks: test PHPUnit i UAT dwóch profili — środowisko lokalne nie ma `vendor/`, UAT wymaga potwierdzenia przeglądarkowego.
|
||||||
|
- Raw outputs: `.paul/codebase/radar/codebase-memory-post-apply.txt`.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
- PHPUnit nie został uruchomiony lokalnie; wykonano lint pliku testowego.
|
||||||
|
- UAT w dwóch profilach nie został potwierdzony przed UNIFY.
|
||||||
|
- Legacy `ROADMAP.md` pominięto w trybie plan-first. Commit fazowy pominięto zgodnie z `preferences.auto_commit: false` oraz z powodu licznych niezwiązanych zmian w working tree.
|
||||||
|
|
||||||
|
## Key Decisions / Patterns
|
||||||
|
|
||||||
|
- Osobny token per urządzenie zamiast wydłużania globalnej sesji.
|
||||||
|
- W bazie przechowywany jest wyłącznie hash walidatora.
|
||||||
|
- Wylogowanie nie usuwa tokenów innych urządzeń.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- Jednorazowo potwierdzić logowanie w dwóch profilach, zamknięcie sesji i wylogowanie tylko jednego profilu.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 247ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ https://finanse.projectpro.pl/login:0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
- generic [ref=e1]:
|
||||||
|
- main [ref=e2]:
|
||||||
|
- generic [ref=e6]:
|
||||||
|
- heading "finansePRO" [level=1] [ref=e7]
|
||||||
|
- generic [ref=e8]:
|
||||||
|
- generic [ref=e9]:
|
||||||
|
- generic [ref=e10]: Email
|
||||||
|
- textbox [active] [ref=e11]
|
||||||
|
- generic [ref=e12]:
|
||||||
|
- generic [ref=e13]: Hasło
|
||||||
|
- textbox [ref=e14]
|
||||||
|
- button "Zaloguj" [ref=e15] [cursor=pointer]
|
||||||
|
- img
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
- generic [active] [ref=f1e1]:
|
||||||
|
- navigation [ref=f1e2]:
|
||||||
|
- generic [ref=f1e3]:
|
||||||
|
- link "finansePRO" [ref=f1e4] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/dashboard
|
||||||
|
- generic [ref=f1e5]:
|
||||||
|
- list [ref=f1e6]:
|
||||||
|
- listitem [ref=f1e7]:
|
||||||
|
- link "Pulpit" [ref=f1e8] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/dashboard
|
||||||
|
- listitem [ref=f1e9]:
|
||||||
|
- button "Finanse bieżące" [ref=f1e10] [cursor=pointer]
|
||||||
|
- listitem [ref=f1e11]:
|
||||||
|
- button "Inwestycje" [ref=f1e12] [cursor=pointer]
|
||||||
|
- generic [ref=f1e13]: jacek.pyziak@project-pro.pl
|
||||||
|
- link "Wyloguj" [ref=f1e14] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/logout
|
||||||
|
- main [ref=f1e15]:
|
||||||
|
- heading "Pulpit" [level=1] [ref=f1e16]
|
||||||
|
- generic [ref=f1e18]:
|
||||||
|
- generic [ref=f1e19]: Majątek razem (saldo bieżące + wartość inwestycji)
|
||||||
|
- generic [ref=f1e20]: 12 329,83 zł
|
||||||
|
- generic [ref=f1e21]:
|
||||||
|
- generic [ref=f1e24]:
|
||||||
|
- generic [ref=f1e25]:
|
||||||
|
- heading "Finanse bieżące (rok 2026)" [level=2] [ref=f1e26]
|
||||||
|
- link "Szczegóły →" [ref=f1e27] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/finances
|
||||||
|
- generic [ref=f1e28]:
|
||||||
|
- generic [ref=f1e29]:
|
||||||
|
- generic [ref=f1e30]: Przychody
|
||||||
|
- generic [ref=f1e31]: 0,00 zł
|
||||||
|
- generic [ref=f1e32]:
|
||||||
|
- generic [ref=f1e33]: Wydatki
|
||||||
|
- generic [ref=f1e34]: 3 233,43 zł
|
||||||
|
- generic [ref=f1e35]:
|
||||||
|
- generic [ref=f1e36]: Saldo
|
||||||
|
- generic [ref=f1e37]: "-3 233,43 zł"
|
||||||
|
- generic [ref=f1e40]:
|
||||||
|
- generic [ref=f1e41]:
|
||||||
|
- heading "Inwestycje" [level=2] [ref=f1e42]
|
||||||
|
- link "Szczegóły →" [ref=f1e43] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/investments
|
||||||
|
- generic [ref=f1e44]:
|
||||||
|
- generic [ref=f1e45]:
|
||||||
|
- generic [ref=f1e46]: Wpłacono netto
|
||||||
|
- generic [ref=f1e47]: 14 300,00 zł
|
||||||
|
- generic [ref=f1e48]:
|
||||||
|
- generic [ref=f1e49]: Wartość
|
||||||
|
- generic [ref=f1e50]: 15 563,26 zł
|
||||||
|
- generic [ref=f1e51]:
|
||||||
|
- generic [ref=f1e52]: Zarobek
|
||||||
|
- generic [ref=f1e53]: 1 263,26 zł
|
||||||
|
- img
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
- generic [active] [ref=f2e1]:
|
||||||
|
- navigation [ref=f2e2]:
|
||||||
|
- generic [ref=f2e3]:
|
||||||
|
- link "finansePRO" [ref=f2e4] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/dashboard
|
||||||
|
- generic [ref=f2e5]:
|
||||||
|
- list [ref=f2e6]:
|
||||||
|
- listitem [ref=f2e7]:
|
||||||
|
- link "Pulpit" [ref=f2e8] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/dashboard
|
||||||
|
- listitem [ref=f2e9]:
|
||||||
|
- button "Finanse bieżące" [ref=f2e10] [cursor=pointer]
|
||||||
|
- listitem [ref=f2e11]:
|
||||||
|
- button "Inwestycje" [ref=f2e12] [cursor=pointer]
|
||||||
|
- generic [ref=f2e13]: jacek.pyziak@project-pro.pl
|
||||||
|
- link "Wyloguj" [ref=f2e14] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/logout
|
||||||
|
- main [ref=f2e15]:
|
||||||
|
- generic [ref=f2e16]:
|
||||||
|
- heading "Operacje" [level=1] [ref=f2e17]
|
||||||
|
- link "+ Nowa operacja" [ref=f2e18] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/new
|
||||||
|
- generic [ref=f2e21]:
|
||||||
|
- generic [ref=f2e22]:
|
||||||
|
- generic [ref=f2e23]: Od
|
||||||
|
- textbox [ref=f2e24]
|
||||||
|
- generic [ref=f2e25]:
|
||||||
|
- generic [ref=f2e26]: Do
|
||||||
|
- textbox [ref=f2e27]
|
||||||
|
- generic [ref=f2e28]:
|
||||||
|
- generic [ref=f2e29]: Kategoria
|
||||||
|
- combobox [ref=f2e30]:
|
||||||
|
- option "— wszystkie —" [selected]
|
||||||
|
- option "Inne wydatki"
|
||||||
|
- option "Inwestycje"
|
||||||
|
- option "Mieszkanie"
|
||||||
|
- option "Nauka"
|
||||||
|
- option "Podróże"
|
||||||
|
- option "Jedzenie"
|
||||||
|
- option "Paliwo"
|
||||||
|
- option "Pamiątki"
|
||||||
|
- option "Parking"
|
||||||
|
- option "Rozrywka"
|
||||||
|
- option "Raty"
|
||||||
|
- option "Rozrywka"
|
||||||
|
- option "Transport"
|
||||||
|
- option "Wynagrodzenie"
|
||||||
|
- option "Premia"
|
||||||
|
- option "Zasadniczne"
|
||||||
|
- option "Zakupy"
|
||||||
|
- option "Chemia gospodarcza"
|
||||||
|
- option "Fastfood"
|
||||||
|
- option "Jedzenie"
|
||||||
|
- option "Słodycze"
|
||||||
|
- option "Zdrowie"
|
||||||
|
- generic [ref=f2e31]:
|
||||||
|
- generic [ref=f2e32]: Typ
|
||||||
|
- combobox [ref=f2e33]:
|
||||||
|
- option "— oba —" [selected]
|
||||||
|
- option "Przychód"
|
||||||
|
- option "Wydatek"
|
||||||
|
- button "Filtruj" [ref=f2e35] [cursor=pointer]
|
||||||
|
- generic [ref=f2e36]:
|
||||||
|
- generic [ref=f2e39]:
|
||||||
|
- generic [ref=f2e40]: Przychody
|
||||||
|
- generic [ref=f2e41]: 0,00 zł
|
||||||
|
- generic [ref=f2e44]:
|
||||||
|
- generic [ref=f2e45]: Wydatki
|
||||||
|
- generic [ref=f2e46]: 3 233,43 zł
|
||||||
|
- generic [ref=f2e49]:
|
||||||
|
- generic [ref=f2e50]: Saldo
|
||||||
|
- generic [ref=f2e51]: "-3 233,43 zł"
|
||||||
|
- table [ref=f2e54]:
|
||||||
|
- rowgroup [ref=f2e55]:
|
||||||
|
- row "Data Kategoria Opis Kwota Akcje" [ref=f2e56]:
|
||||||
|
- columnheader "Data" [ref=f2e57]
|
||||||
|
- columnheader "Kategoria" [ref=f2e58]
|
||||||
|
- columnheader "Opis" [ref=f2e59]
|
||||||
|
- columnheader "Kwota" [ref=f2e60]
|
||||||
|
- columnheader "Akcje" [ref=f2e61]
|
||||||
|
- rowgroup [ref=f2e62]:
|
||||||
|
- 'row "2026-07-06 Zakupy → Jedzenie Biedronka: Jabłka polskie Gala luz, Jogurt pitny truskawkowy 400 g, Jogurt pitny brzoskwiniowy 400 g, Mleko 3,2% 1 l, Winogrona jasne bezpestkowe luz, Cebula żółta luz, Gofry Pano 250 g, Marchew luz, Sok jabłkowy Vital Fresh 1 l, Dzik Zero Orange, Banany −67,74 zł Edytuj Usuń" [ref=f2e63]':
|
||||||
|
- cell "2026-07-06" [ref=f2e64]
|
||||||
|
- cell "Zakupy → Jedzenie" [ref=f2e65]
|
||||||
|
- 'cell "Biedronka: Jabłka polskie Gala luz, Jogurt pitny truskawkowy 400 g, Jogurt pitny brzoskwiniowy 400 g, Mleko 3,2% 1 l, Winogrona jasne bezpestkowe luz, Cebula żółta luz, Gofry Pano 250 g, Marchew luz, Sok jabłkowy Vital Fresh 1 l, Dzik Zero Orange, Banany" [ref=f2e66]'
|
||||||
|
- cell "−67,74 zł" [ref=f2e67]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e68]:
|
||||||
|
- link "Edytuj" [ref=f2e69] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/47/edit
|
||||||
|
- link "Usuń" [ref=f2e70] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/47/delete
|
||||||
|
- row "2026-07-06 Inwestycje ETF −300,00 zł Edytuj Usuń" [ref=f2e71]:
|
||||||
|
- cell "2026-07-06" [ref=f2e72]
|
||||||
|
- cell "Inwestycje" [ref=f2e73]
|
||||||
|
- cell "ETF" [ref=f2e74]
|
||||||
|
- cell "−300,00 zł" [ref=f2e75]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e76]:
|
||||||
|
- link "Edytuj" [ref=f2e77] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/46/edit
|
||||||
|
- link "Usuń" [ref=f2e78] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/46/delete
|
||||||
|
- row "2026-07-06 Nauka Indigo Asia −399,00 zł Edytuj Usuń" [ref=f2e79]:
|
||||||
|
- cell "2026-07-06" [ref=f2e80]
|
||||||
|
- cell "Nauka" [ref=f2e81]
|
||||||
|
- cell "Indigo Asia" [ref=f2e82]
|
||||||
|
- cell "−399,00 zł" [ref=f2e83]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e84]:
|
||||||
|
- link "Edytuj" [ref=f2e85] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/44/edit
|
||||||
|
- link "Usuń" [ref=f2e86] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/44/delete
|
||||||
|
- row "2026-07-06 Raty −8,71 zł Edytuj Usuń" [ref=f2e87]:
|
||||||
|
- cell "2026-07-06" [ref=f2e88]
|
||||||
|
- cell "Raty" [ref=f2e89]
|
||||||
|
- cell [ref=f2e90]
|
||||||
|
- cell "−8,71 zł" [ref=f2e91]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e92]:
|
||||||
|
- link "Edytuj" [ref=f2e93] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/43/edit
|
||||||
|
- link "Usuń" [ref=f2e94] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/43/delete
|
||||||
|
- row "2026-07-06 Zakupy −20,96 zł Edytuj Usuń" [ref=f2e95]:
|
||||||
|
- cell "2026-07-06" [ref=f2e96]
|
||||||
|
- cell "Zakupy" [ref=f2e97]
|
||||||
|
- cell [ref=f2e98]
|
||||||
|
- cell "−20,96 zł" [ref=f2e99]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e100]:
|
||||||
|
- link "Edytuj" [ref=f2e101] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/42/edit
|
||||||
|
- link "Usuń" [ref=f2e102] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/42/delete
|
||||||
|
- row "2026-07-06 Raty −129,90 zł Edytuj Usuń" [ref=f2e103]:
|
||||||
|
- cell "2026-07-06" [ref=f2e104]
|
||||||
|
- cell "Raty" [ref=f2e105]
|
||||||
|
- cell [ref=f2e106]
|
||||||
|
- cell "−129,90 zł" [ref=f2e107]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e108]:
|
||||||
|
- link "Edytuj" [ref=f2e109] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/41/edit
|
||||||
|
- link "Usuń" [ref=f2e110] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/41/delete
|
||||||
|
- row "2026-07-06 Inwestycje Obligacje −300,00 zł Edytuj Usuń" [ref=f2e111]:
|
||||||
|
- cell "2026-07-06" [ref=f2e112]
|
||||||
|
- cell "Inwestycje" [ref=f2e113]
|
||||||
|
- cell "Obligacje" [ref=f2e114]
|
||||||
|
- cell "−300,00 zł" [ref=f2e115]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e116]:
|
||||||
|
- link "Edytuj" [ref=f2e117] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/40/edit
|
||||||
|
- link "Usuń" [ref=f2e118] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/40/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −75,60 zł Edytuj Usuń" [ref=f2e119]:
|
||||||
|
- cell "2026-07-06" [ref=f2e120]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e121]
|
||||||
|
- cell [ref=f2e122]
|
||||||
|
- cell "−75,60 zł" [ref=f2e123]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e124]:
|
||||||
|
- link "Edytuj" [ref=f2e125] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/39/edit
|
||||||
|
- link "Usuń" [ref=f2e126] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/39/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −44,87 zł Edytuj Usuń" [ref=f2e127]:
|
||||||
|
- cell "2026-07-06" [ref=f2e128]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e129]
|
||||||
|
- cell [ref=f2e130]
|
||||||
|
- cell "−44,87 zł" [ref=f2e131]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e132]:
|
||||||
|
- link "Edytuj" [ref=f2e133] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/38/edit
|
||||||
|
- link "Usuń" [ref=f2e134] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/38/delete
|
||||||
|
- row "2026-07-06 Podróże → Paliwo −183,62 zł Edytuj Usuń" [ref=f2e135]:
|
||||||
|
- cell "2026-07-06" [ref=f2e136]
|
||||||
|
- cell "Podróże → Paliwo" [ref=f2e137]
|
||||||
|
- cell [ref=f2e138]
|
||||||
|
- cell "−183,62 zł" [ref=f2e139]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e140]:
|
||||||
|
- link "Edytuj" [ref=f2e141] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/37/edit
|
||||||
|
- link "Usuń" [ref=f2e142] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/37/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −44,00 zł Edytuj Usuń" [ref=f2e143]:
|
||||||
|
- cell "2026-07-06" [ref=f2e144]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e145]
|
||||||
|
- cell [ref=f2e146]
|
||||||
|
- cell "−44,00 zł" [ref=f2e147]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e148]:
|
||||||
|
- link "Edytuj" [ref=f2e149] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/36/edit
|
||||||
|
- link "Usuń" [ref=f2e150] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/36/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −50,00 zł Edytuj Usuń" [ref=f2e151]:
|
||||||
|
- cell "2026-07-06" [ref=f2e152]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e153]
|
||||||
|
- cell [ref=f2e154]
|
||||||
|
- cell "−50,00 zł" [ref=f2e155]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e156]:
|
||||||
|
- link "Edytuj" [ref=f2e157] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/35/edit
|
||||||
|
- link "Usuń" [ref=f2e158] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/35/delete
|
||||||
|
- row "2026-07-06 Podróże → Rozrywka −12,00 zł Edytuj Usuń" [ref=f2e159]:
|
||||||
|
- cell "2026-07-06" [ref=f2e160]
|
||||||
|
- cell "Podróże → Rozrywka" [ref=f2e161]
|
||||||
|
- cell [ref=f2e162]
|
||||||
|
- cell "−12,00 zł" [ref=f2e163]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e164]:
|
||||||
|
- link "Edytuj" [ref=f2e165] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/34/edit
|
||||||
|
- link "Usuń" [ref=f2e166] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/34/delete
|
||||||
|
- row "2026-07-06 Podróże → Rozrywka −20,00 zł Edytuj Usuń" [ref=f2e167]:
|
||||||
|
- cell "2026-07-06" [ref=f2e168]
|
||||||
|
- cell "Podróże → Rozrywka" [ref=f2e169]
|
||||||
|
- cell [ref=f2e170]
|
||||||
|
- cell "−20,00 zł" [ref=f2e171]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e172]:
|
||||||
|
- link "Edytuj" [ref=f2e173] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/33/edit
|
||||||
|
- link "Usuń" [ref=f2e174] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/33/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −35,00 zł Edytuj Usuń" [ref=f2e175]:
|
||||||
|
- cell "2026-07-06" [ref=f2e176]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e177]
|
||||||
|
- cell [ref=f2e178]
|
||||||
|
- cell "−35,00 zł" [ref=f2e179]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e180]:
|
||||||
|
- link "Edytuj" [ref=f2e181] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/32/edit
|
||||||
|
- link "Usuń" [ref=f2e182] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/32/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −34,00 zł Edytuj Usuń" [ref=f2e183]:
|
||||||
|
- cell "2026-07-06" [ref=f2e184]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e185]
|
||||||
|
- cell [ref=f2e186]
|
||||||
|
- cell "−34,00 zł" [ref=f2e187]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e188]:
|
||||||
|
- link "Edytuj" [ref=f2e189] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/31/edit
|
||||||
|
- link "Usuń" [ref=f2e190] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/31/delete
|
||||||
|
- row "2026-07-06 Podróże → Pamiątki −10,00 zł Edytuj Usuń" [ref=f2e191]:
|
||||||
|
- cell "2026-07-06" [ref=f2e192]
|
||||||
|
- cell "Podróże → Pamiątki" [ref=f2e193]
|
||||||
|
- cell [ref=f2e194]
|
||||||
|
- cell "−10,00 zł" [ref=f2e195]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e196]:
|
||||||
|
- link "Edytuj" [ref=f2e197] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/30/edit
|
||||||
|
- link "Usuń" [ref=f2e198] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/30/delete
|
||||||
|
- row "2026-07-06 Podróże → Pamiątki −5,00 zł Edytuj Usuń" [ref=f2e199]:
|
||||||
|
- cell "2026-07-06" [ref=f2e200]
|
||||||
|
- cell "Podróże → Pamiątki" [ref=f2e201]
|
||||||
|
- cell [ref=f2e202]
|
||||||
|
- cell "−5,00 zł" [ref=f2e203]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e204]:
|
||||||
|
- link "Edytuj" [ref=f2e205] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/29/edit
|
||||||
|
- link "Usuń" [ref=f2e206] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/29/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −31,42 zł Edytuj Usuń" [ref=f2e207]:
|
||||||
|
- cell "2026-07-06" [ref=f2e208]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e209]
|
||||||
|
- cell [ref=f2e210]
|
||||||
|
- cell "−31,42 zł" [ref=f2e211]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e212]:
|
||||||
|
- link "Edytuj" [ref=f2e213] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/28/edit
|
||||||
|
- link "Usuń" [ref=f2e214] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/28/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −21,98 zł Edytuj Usuń" [ref=f2e215]:
|
||||||
|
- cell "2026-07-06" [ref=f2e216]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e217]
|
||||||
|
- cell [ref=f2e218]
|
||||||
|
- cell "−21,98 zł" [ref=f2e219]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e220]:
|
||||||
|
- link "Edytuj" [ref=f2e221] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/27/edit
|
||||||
|
- link "Usuń" [ref=f2e222] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/27/delete
|
||||||
|
- row "2026-07-06 Podróże → Parking −20,00 zł Edytuj Usuń" [ref=f2e223]:
|
||||||
|
- cell "2026-07-06" [ref=f2e224]
|
||||||
|
- cell "Podróże → Parking" [ref=f2e225]
|
||||||
|
- cell [ref=f2e226]
|
||||||
|
- cell "−20,00 zł" [ref=f2e227]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e228]:
|
||||||
|
- link "Edytuj" [ref=f2e229] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/26/edit
|
||||||
|
- link "Usuń" [ref=f2e230] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/26/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −56,92 zł Edytuj Usuń" [ref=f2e231]:
|
||||||
|
- cell "2026-07-06" [ref=f2e232]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e233]
|
||||||
|
- cell [ref=f2e234]
|
||||||
|
- cell "−56,92 zł" [ref=f2e235]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e236]:
|
||||||
|
- link "Edytuj" [ref=f2e237] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/25/edit
|
||||||
|
- link "Usuń" [ref=f2e238] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/25/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −5,00 zł Edytuj Usuń" [ref=f2e239]:
|
||||||
|
- cell "2026-07-06" [ref=f2e240]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e241]
|
||||||
|
- cell [ref=f2e242]
|
||||||
|
- cell "−5,00 zł" [ref=f2e243]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e244]:
|
||||||
|
- link "Edytuj" [ref=f2e245] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/24/edit
|
||||||
|
- link "Usuń" [ref=f2e246] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/24/delete
|
||||||
|
- row "2026-07-06 Podróże → Rozrywka −616,00 zł Edytuj Usuń" [ref=f2e247]:
|
||||||
|
- cell "2026-07-06" [ref=f2e248]
|
||||||
|
- cell "Podróże → Rozrywka" [ref=f2e249]
|
||||||
|
- cell [ref=f2e250]
|
||||||
|
- cell "−616,00 zł" [ref=f2e251]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e252]:
|
||||||
|
- link "Edytuj" [ref=f2e253] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/23/edit
|
||||||
|
- link "Usuń" [ref=f2e254] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/23/delete
|
||||||
|
- row "2026-07-06 Podróże → Pamiątki −31,00 zł Edytuj Usuń" [ref=f2e255]:
|
||||||
|
- cell "2026-07-06" [ref=f2e256]
|
||||||
|
- cell "Podróże → Pamiątki" [ref=f2e257]
|
||||||
|
- cell [ref=f2e258]
|
||||||
|
- cell "−31,00 zł" [ref=f2e259]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e260]:
|
||||||
|
- link "Edytuj" [ref=f2e261] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/22/edit
|
||||||
|
- link "Usuń" [ref=f2e262] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/22/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −12,90 zł Edytuj Usuń" [ref=f2e263]:
|
||||||
|
- cell "2026-07-06" [ref=f2e264]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e265]
|
||||||
|
- cell [ref=f2e266]
|
||||||
|
- cell "−12,90 zł" [ref=f2e267]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e268]:
|
||||||
|
- link "Edytuj" [ref=f2e269] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/21/edit
|
||||||
|
- link "Usuń" [ref=f2e270] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/21/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −16,37 zł Edytuj Usuń" [ref=f2e271]:
|
||||||
|
- cell "2026-07-06" [ref=f2e272]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e273]
|
||||||
|
- cell [ref=f2e274]
|
||||||
|
- cell "−16,37 zł" [ref=f2e275]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e276]:
|
||||||
|
- link "Edytuj" [ref=f2e277] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/20/edit
|
||||||
|
- link "Usuń" [ref=f2e278] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/20/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −11,50 zł Edytuj Usuń" [ref=f2e279]:
|
||||||
|
- cell "2026-07-06" [ref=f2e280]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e281]
|
||||||
|
- cell [ref=f2e282]
|
||||||
|
- cell "−11,50 zł" [ref=f2e283]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e284]:
|
||||||
|
- link "Edytuj" [ref=f2e285] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/19/edit
|
||||||
|
- link "Usuń" [ref=f2e286] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/19/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −42,47 zł Edytuj Usuń" [ref=f2e287]:
|
||||||
|
- cell "2026-07-06" [ref=f2e288]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e289]
|
||||||
|
- cell [ref=f2e290]
|
||||||
|
- cell "−42,47 zł" [ref=f2e291]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e292]:
|
||||||
|
- link "Edytuj" [ref=f2e293] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/18/edit
|
||||||
|
- link "Usuń" [ref=f2e294] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/18/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −146,00 zł Edytuj Usuń" [ref=f2e295]:
|
||||||
|
- cell "2026-07-06" [ref=f2e296]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e297]
|
||||||
|
- cell [ref=f2e298]
|
||||||
|
- cell "−146,00 zł" [ref=f2e299]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e300]:
|
||||||
|
- link "Edytuj" [ref=f2e301] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/17/edit
|
||||||
|
- link "Usuń" [ref=f2e302] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/17/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −10,19 zł Edytuj Usuń" [ref=f2e303]:
|
||||||
|
- cell "2026-07-06" [ref=f2e304]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e305]
|
||||||
|
- cell [ref=f2e306]
|
||||||
|
- cell "−10,19 zł" [ref=f2e307]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e308]:
|
||||||
|
- link "Edytuj" [ref=f2e309] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/16/edit
|
||||||
|
- link "Usuń" [ref=f2e310] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/16/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −76,80 zł Edytuj Usuń" [ref=f2e311]:
|
||||||
|
- cell "2026-07-06" [ref=f2e312]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e313]
|
||||||
|
- cell [ref=f2e314]
|
||||||
|
- cell "−76,80 zł" [ref=f2e315]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e316]:
|
||||||
|
- link "Edytuj" [ref=f2e317] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/15/edit
|
||||||
|
- link "Usuń" [ref=f2e318] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/15/delete
|
||||||
|
- row "2026-07-06 Podróże → Paliwo −84,21 zł Edytuj Usuń" [ref=f2e319]:
|
||||||
|
- cell "2026-07-06" [ref=f2e320]
|
||||||
|
- cell "Podróże → Paliwo" [ref=f2e321]
|
||||||
|
- cell [ref=f2e322]
|
||||||
|
- cell "−84,21 zł" [ref=f2e323]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e324]:
|
||||||
|
- link "Edytuj" [ref=f2e325] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/14/edit
|
||||||
|
- link "Usuń" [ref=f2e326] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/14/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −9,49 zł Edytuj Usuń" [ref=f2e327]:
|
||||||
|
- cell "2026-07-06" [ref=f2e328]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e329]
|
||||||
|
- cell [ref=f2e330]
|
||||||
|
- cell "−9,49 zł" [ref=f2e331]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e332]:
|
||||||
|
- link "Edytuj" [ref=f2e333] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/13/edit
|
||||||
|
- link "Usuń" [ref=f2e334] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/13/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −9,49 zł Edytuj Usuń" [ref=f2e335]:
|
||||||
|
- cell "2026-07-06" [ref=f2e336]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e337]
|
||||||
|
- cell [ref=f2e338]
|
||||||
|
- cell "−9,49 zł" [ref=f2e339]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e340]:
|
||||||
|
- link "Edytuj" [ref=f2e341] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/12/edit
|
||||||
|
- link "Usuń" [ref=f2e342] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/12/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −7,19 zł Edytuj Usuń" [ref=f2e343]:
|
||||||
|
- cell "2026-07-06" [ref=f2e344]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e345]
|
||||||
|
- cell [ref=f2e346]
|
||||||
|
- cell "−7,19 zł" [ref=f2e347]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e348]:
|
||||||
|
- link "Edytuj" [ref=f2e349] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/11/edit
|
||||||
|
- link "Usuń" [ref=f2e350] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/11/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −22,87 zł Edytuj Usuń" [ref=f2e351]:
|
||||||
|
- cell "2026-07-06" [ref=f2e352]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e353]
|
||||||
|
- cell [ref=f2e354]
|
||||||
|
- cell "−22,87 zł" [ref=f2e355]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e356]:
|
||||||
|
- link "Edytuj" [ref=f2e357] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/10/edit
|
||||||
|
- link "Usuń" [ref=f2e358] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/10/delete
|
||||||
|
- row "2026-07-06 Podróże → Jedzenie −31,00 zł Edytuj Usuń" [ref=f2e359]:
|
||||||
|
- cell "2026-07-06" [ref=f2e360]
|
||||||
|
- cell "Podróże → Jedzenie" [ref=f2e361]
|
||||||
|
- cell [ref=f2e362]
|
||||||
|
- cell "−31,00 zł" [ref=f2e363]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e364]:
|
||||||
|
- link "Edytuj" [ref=f2e365] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/9/edit
|
||||||
|
- link "Usuń" [ref=f2e366] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/9/delete
|
||||||
|
- row "2026-07-06 Podróże → Parking −28,60 zł Edytuj Usuń" [ref=f2e367]:
|
||||||
|
- cell "2026-07-06" [ref=f2e368]
|
||||||
|
- cell "Podróże → Parking" [ref=f2e369]
|
||||||
|
- cell [ref=f2e370]
|
||||||
|
- cell "−28,60 zł" [ref=f2e371]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e372]:
|
||||||
|
- link "Edytuj" [ref=f2e373] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/8/edit
|
||||||
|
- link "Usuń" [ref=f2e374] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/8/delete
|
||||||
|
- row "2026-07-06 Zakupy −127,69 zł Edytuj Usuń" [ref=f2e375]:
|
||||||
|
- cell "2026-07-06" [ref=f2e376]
|
||||||
|
- cell "Zakupy" [ref=f2e377]
|
||||||
|
- cell [ref=f2e378]
|
||||||
|
- cell "−127,69 zł" [ref=f2e379]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e380]:
|
||||||
|
- link "Edytuj" [ref=f2e381] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/3/edit
|
||||||
|
- link "Usuń" [ref=f2e382] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/3/delete
|
||||||
|
- row "2026-07-06 Zakupy −65,45 zł Edytuj Usuń" [ref=f2e383]:
|
||||||
|
- cell "2026-07-06" [ref=f2e384]
|
||||||
|
- cell "Zakupy" [ref=f2e385]
|
||||||
|
- cell [ref=f2e386]
|
||||||
|
- cell "−65,45 zł" [ref=f2e387]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e388]:
|
||||||
|
- link "Edytuj" [ref=f2e389] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/2/edit
|
||||||
|
- link "Usuń" [ref=f2e390] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/2/delete
|
||||||
|
- row "2026-07-06 Zakupy −8,49 zł Edytuj Usuń" [ref=f2e391]:
|
||||||
|
- cell "2026-07-06" [ref=f2e392]
|
||||||
|
- cell "Zakupy" [ref=f2e393]
|
||||||
|
- cell [ref=f2e394]
|
||||||
|
- cell "−8,49 zł" [ref=f2e395]
|
||||||
|
- cell "Edytuj Usuń" [ref=f2e396]:
|
||||||
|
- link "Edytuj" [ref=f2e397] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/1/edit
|
||||||
|
- link "Usuń" [ref=f2e398] [cursor=pointer]:
|
||||||
|
- /url: https://finanse.projectpro.pl/operations/1/delete
|
||||||
|
- img
|
||||||
Vendored
+460
@@ -0,0 +1,460 @@
|
|||||||
|
{
|
||||||
|
"ftp://host700513.hostido.net.pl:21@www@finanse.projectpro.pl": {
|
||||||
|
"public_html": {
|
||||||
|
"app": {
|
||||||
|
"Database": {
|
||||||
|
"Migrations": {
|
||||||
|
"2026-07-06-100001_CreateCategories.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1353,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-100002_CreateOperations.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1377,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-140001_CreateInvInstruments.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 812,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-140002_CreateInvOperations.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1423,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-140003_CreateInvValuations.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1200,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-200001_CreateReceipts.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1500,
|
||||||
|
"lmtime": 1783364101070,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-200002_CreateReceiptItems.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1781,
|
||||||
|
"lmtime": 1783364108533,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-200003_CreateReceiptCategoryMap.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1037,
|
||||||
|
"lmtime": 1783364113574,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-200004_AddReceiptIdToOperations.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 747,
|
||||||
|
"lmtime": 1783364116680,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
".gitkeep": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 0,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-07-06-200005_AddDisplayNameToReceiptItems.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 580,
|
||||||
|
"lmtime": 1783366821444,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Models": {
|
||||||
|
"CategoryModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3110,
|
||||||
|
"lmtime": 1783367402037,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
".gitkeep": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 0,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"InstrumentModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 5312,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"InvOperationModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1578,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"OperationModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 5924,
|
||||||
|
"lmtime": 1783368871963,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"ReceiptCategoryMapModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1890,
|
||||||
|
"lmtime": 1783364165741,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"ReceiptItemModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 670,
|
||||||
|
"lmtime": 1783366836342,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"ReceiptModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 937,
|
||||||
|
"lmtime": 1783364152838,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"ValuationModel.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1345,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Libraries": {
|
||||||
|
".gitkeep": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 0,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"ReceiptParser.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 7090,
|
||||||
|
"lmtime": 1783366864902,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Controllers": {
|
||||||
|
"Auth.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1314,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"BaseController.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1349,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Categories.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2499,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Dashboard.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1948,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Home.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 161,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Instruments.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2059,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Investments.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 929,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"InvOperations.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2940,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Operations.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3467,
|
||||||
|
"lmtime": 1783368879764,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Receipts.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 11901,
|
||||||
|
"lmtime": 1783366894415,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"Valuations.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2405,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Config": {
|
||||||
|
"Routes.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3384,
|
||||||
|
"lmtime": 1783364273064,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Views": {
|
||||||
|
"auth": {},
|
||||||
|
"categories": {},
|
||||||
|
"dashboard": {},
|
||||||
|
"errors": {},
|
||||||
|
"instruments": {},
|
||||||
|
"investments": {},
|
||||||
|
"inv_operations": {},
|
||||||
|
"layout": {
|
||||||
|
"main.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3815,
|
||||||
|
"lmtime": 1783364283479,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
"form.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3663,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"index.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 7243,
|
||||||
|
"lmtime": 1783368918877,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"valuations": {},
|
||||||
|
"welcome_message.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 17352,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"receipts": {
|
||||||
|
"index.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2713,
|
||||||
|
"lmtime": 1783364332599,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"new.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1165,
|
||||||
|
"lmtime": 1783364292863,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"review.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 5620,
|
||||||
|
"lmtime": 1783367409167,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"show.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 4062,
|
||||||
|
"lmtime": 1783366909680,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"builds": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 4129,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"cgi-bin": {},
|
||||||
|
".claude": {},
|
||||||
|
"composer.json": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1088,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"composer.lock": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 77771,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
".env": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1008,
|
||||||
|
"lmtime": 1783366618371,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
".gitignore": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1858,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
".htaccess": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1084,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"LICENSE": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1162,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"operations-before.png": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 227909,
|
||||||
|
"lmtime": 1783368826240,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
".paul": {
|
||||||
|
"changelog": {
|
||||||
|
"2026-07-06.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 4897,
|
||||||
|
"lmtime": 1783367953541,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"codebase": {
|
||||||
|
"impact_map.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3932,
|
||||||
|
"lmtime": 1783368033490,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"quality_risks.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2521,
|
||||||
|
"lmtime": 1783368045743,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"radar": {},
|
||||||
|
"tooling_status.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1103,
|
||||||
|
"lmtime": 1783364442507,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 729,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"handoffs": {},
|
||||||
|
"plans": {
|
||||||
|
"20260706-2046-import-paragonow": {
|
||||||
|
"PLAN.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 19453,
|
||||||
|
"lmtime": 1783363706084,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"SUMMARY.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 6214,
|
||||||
|
"lmtime": 1783367919539,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PROJECT.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 645,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"rules": {},
|
||||||
|
"STATE.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2648,
|
||||||
|
"lmtime": 1783368004681,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"phpunit.dist.xml": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2422,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
".playwright-mcp": {
|
||||||
|
"console-2026-07-06T20-13-14-268Z.log": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 191,
|
||||||
|
"lmtime": 1783368794531,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"page-2026-07-06T20-13-14-565Z.yml": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 399,
|
||||||
|
"lmtime": 1783368794567,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"page-2026-07-06T20-13-40-056Z.yml": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2343,
|
||||||
|
"lmtime": 1783368820058,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"page-2026-07-06T20-13-43-467Z.yml": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 27212,
|
||||||
|
"lmtime": 1783368823469,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preload.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3403,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"public": {},
|
||||||
|
"README.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3034,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"spark": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2668,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"tests": {},
|
||||||
|
"vendor": {},
|
||||||
|
"writable": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$version": 1
|
||||||
|
}
|
||||||
@@ -23,6 +23,16 @@ $routes->group('', ['filter' => 'auth'], static function (RouteCollection $route
|
|||||||
$routes->post('categories/(:num)', 'Categories::update/$1');
|
$routes->post('categories/(:num)', 'Categories::update/$1');
|
||||||
$routes->get('categories/(:num)/delete', 'Categories::delete/$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
|
// Operacje
|
||||||
$routes->get('operations', 'Operations::index');
|
$routes->get('operations', 'Operations::index');
|
||||||
$routes->get('operations/new', 'Operations::new');
|
$routes->get('operations/new', 'Operations::new');
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Models\RememberTokenModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logowanie jednego uzytkownika wprost z .env (user_email + user_password).
|
* Logowanie jednego uzytkownika wprost z .env (user_email + user_password).
|
||||||
* ponytail: jawne haslo z .env, narzedzie jednoosobowe. Podniesc do password_hash,
|
* ponytail: jawne haslo z .env, narzedzie jednoosobowe. Podniesc do password_hash,
|
||||||
@@ -28,7 +30,22 @@ class Auth extends BaseController
|
|||||||
'user_email' => $email,
|
'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.');
|
return redirect()->back()->with('error', 'Nieprawidłowy email lub hasło.');
|
||||||
@@ -39,8 +56,13 @@ class Auth extends BaseController
|
|||||||
|
|
||||||
public function logout()
|
public function logout()
|
||||||
{
|
{
|
||||||
|
$token = (string) $this->request->getCookie('remember_token');
|
||||||
|
if ($token !== '') {
|
||||||
|
(new RememberTokenModel())->deleteToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
session()->destroy();
|
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;
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use App\Models\RememberTokenModel;
|
||||||
use CodeIgniter\Filters\FilterInterface;
|
use CodeIgniter\Filters\FilterInterface;
|
||||||
use CodeIgniter\HTTP\RequestInterface;
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
use CodeIgniter\HTTP\ResponseInterface;
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
@@ -13,9 +14,21 @@ class AuthFilter implements FilterInterface
|
|||||||
{
|
{
|
||||||
public function before(RequestInterface $request, $arguments = null)
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
{
|
{
|
||||||
if (! session()->get('logged_in')) {
|
if (session()->get('logged_in')) {
|
||||||
return redirect()->to('/login');
|
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)
|
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
|
$indent = "\u{00A0}\u{00A0}\u{00A0}\u{00A0}"; // 4x twarda spacja na poziom
|
||||||
$out = [];
|
$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) {
|
foreach ($childrenByParent[$parentId] ?? [] as $c) {
|
||||||
$c['depth'] = $depth;
|
$c['depth'] = $depth;
|
||||||
$c['indent_label'] = str_repeat($indent, $depth) . $c['name'];
|
$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;
|
$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
|
// Sieroty (np. rodzic odfiltrowany typem) — dopnij na koncu bez wciecia
|
||||||
$seen = array_column($out, null, 'id');
|
$seen = array_column($out, null, 'id');
|
||||||
@@ -76,6 +78,7 @@ class CategoryModel extends Model
|
|||||||
if (! isset($seen[$c['id']])) {
|
if (! isset($seen[$c['id']])) {
|
||||||
$c['depth'] = 0;
|
$c['depth'] = 0;
|
||||||
$c['indent_label'] = $c['name'];
|
$c['indent_label'] = $c['name'];
|
||||||
|
$c['path_label'] = $c['name'];
|
||||||
$out[] = $c;
|
$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>
|
<label class="form-label">Hasło</label>
|
||||||
<input type="password" name="password" class="form-control" required>
|
<input type="password" name="password" class="form-control" required>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-primary w-100">Zaloguj</button>
|
||||||
<?= form_close() ?>
|
<?= form_close() ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<ul class="dropdown-menu" aria-labelledby="navFinance">
|
<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('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('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>
|
<li><a class="dropdown-item" href="<?= site_url('categories') ?>">Kategorie</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -55,6 +56,9 @@
|
|||||||
<?php if (session()->getFlashdata('success')): ?>
|
<?php if (session()->getFlashdata('success')): ?>
|
||||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (session()->getFlashdata('warning')): ?>
|
||||||
|
<div class="alert alert-warning"><?= esc(session()->getFlashdata('warning')) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?= $this->renderSection('content') ?>
|
<?= $this->renderSection('content') ?>
|
||||||
</main>
|
</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() ?>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\ReceiptModel;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use CodeIgniter\Test\DatabaseTestTrait;
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
final class ReceiptModelTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
use DatabaseTestTrait;
|
||||||
|
|
||||||
|
protected $namespace = 'App';
|
||||||
|
protected $refresh = true;
|
||||||
|
|
||||||
|
public function testDetectsPreviouslyImportedFilename(): void
|
||||||
|
{
|
||||||
|
$model = new ReceiptModel();
|
||||||
|
$model->insert([
|
||||||
|
'file_path' => 'receipts/stored.pdf',
|
||||||
|
'original_name' => 'paragon.pdf',
|
||||||
|
'mime' => 'application/pdf',
|
||||||
|
'status' => 'parsed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($model->hasOriginalName('paragon.pdf'));
|
||||||
|
$this->assertFalse($model->hasOriginalName('inny.pdf'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\RememberTokenModel;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use CodeIgniter\Test\DatabaseTestTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class RememberTokenModelTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
use DatabaseTestTrait;
|
||||||
|
|
||||||
|
protected $namespace = 'App';
|
||||||
|
protected $refresh = true;
|
||||||
|
|
||||||
|
public function testTokensRemainIndependentAcrossDevices(): void
|
||||||
|
{
|
||||||
|
$model = new RememberTokenModel();
|
||||||
|
$first = $model->issueToken();
|
||||||
|
$second = $model->issueToken();
|
||||||
|
|
||||||
|
$this->assertTrue($model->verifyToken($first));
|
||||||
|
$this->assertTrue($model->verifyToken($second));
|
||||||
|
|
||||||
|
$tampered = substr($first, 0, -1) . (str_ends_with($first, '0') ? '1' : '0');
|
||||||
|
$this->assertFalse($model->verifyToken($tampered));
|
||||||
|
|
||||||
|
$model->deleteToken($first);
|
||||||
|
|
||||||
|
$this->assertFalse($model->verifyToken($first));
|
||||||
|
$this->assertTrue($model->verifyToken($second));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user