This commit is contained in:
2026-07-12 19:21:35 +02:00
parent f2d944b117
commit 41cb412cb0
49 changed files with 3170 additions and 52 deletions
@@ -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.