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>