19 KiB
19 KiB
plan_id, title, storage, legacy_phase, created, status, type, autonomous, delegation, files_modified, quality_radar
| plan_id | title | storage | legacy_phase | created | status | type | autonomous | delegation | files_modified | quality_radar | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 20260706-2046-import-paragonow | Półautomatyczny import paragonów (AI parsowanie + uczone podpowiedzi kategorii) | plan-first | null | 2026-07-06T20:46 | planned | execute | false | auto |
|
degraded |
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) + kolumnaoperations.receipt_id. - Warstwa
ReceiptParserdo komunikacji z OpenAI (bez nowej zależności — CI4 CURLRequest). - Historia importów z podglądem wgranego skanu.
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ą.<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 przezCategoryModel::flatList('expense'). - Routing / nawigacja: nowa grupa tras
receipts/*wapp/Config/Routes.php(grupaauth); nowy link wlayout/main.php. - Storage: pliki skanów w
writable/uploads/receipts/(pozapublic/); serwowane przez akcję kontrolera pod filtremauth. - Konfiguracja:
.env—OPEN_AI_API(istnieje) + opcjonalnyopenai.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>
<acceptance_criteria>
AC-1: Formularz uploadu przyjmuje jpg/png/pdf/json
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
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
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ć
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ń
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
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>
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'=>. - 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, `` 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ć `Import paragonów`. Pełny przepływ w przeglądarce: upload → review → confirm → operacje widoczne w /operations; nav działa AC-1, AC-4, AC-5, AC-6 ## 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). - [ ] `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). <success_criteria> Wszystkie AC-1..AC-6 przechodzą (weryfikacja manualna w przeglądarce)..env: klucz odczytywany, model konfigurowalny; sekret nie trafia do repo/logów.