--- 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 --- ## 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. ## 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 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ą. ## 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ź). Brak `.paul/SPECIAL-FLOWS.md` — sekcja skills pominięta. ## 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/ ``` Task 1: Migracje — receipts, receipt_items, receipt_category_map, operations.receipt_id 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 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ę. php spark migrate; sprawdź 4 nowe struktury (SHOW TABLES / DESCRIBE operations) AC-1, AC-5 (schema); zgodne z impact_map (jedno źródło prawdy operacji) Task 2: Modele — ReceiptModel, ReceiptItemModel, ReceiptCategoryMapModel app/Models/ReceiptModel.php, app/Models/ReceiptItemModel.php, app/Models/ReceiptCategoryMapModel.php 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). Ręczny test w tinker/kontrolerze: remember('Mleko 3.2%', 5) → suggest(' mleko 3.2% ') === 5 AC-2, AC-3 Task 3: ReceiptParser — warstwa OpenAI (jpg/png vision, json tekst, pdf file input) app/Libraries/ReceiptParser.php, .env 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. Testowy jpg paragonu → parse() zwraca ≥1 pozycję z kwotą; błędny klucz → wyjątek z komunikatem AC-2 Task 4: Kontroler Receipts + trasy — upload, parse, review app/Controllers/Receipts.php, app/Config/Routes.php 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). Upload testowego pliku → rekord receipt 'parsed' + wiersze receipt_items; /receipts/(:num)/review renderuje AC-1, AC-2, AC-3, AC-6 Task 5: Zatwierdzenie — grupowanie po kategorii, zapis operacji, uczenie mapowań app/Controllers/Receipts.php 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. Paragon z 3 kategoriami → 3 operacje (sumy), wpisy w receipt_category_map; ponowny confirm zablokowany AC-4, AC-5 Task 6: Widoki + nawigacja 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 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 ``, 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, `