update
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user