183 lines
7.5 KiB
PHP
183 lines
7.5 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries;
|
|
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Parsowanie paragonu przez OpenAI Responses API (/v1/responses).
|
|
* Wejscie: obraz (input_image), PDF (input_file — OpenAI renderuje strony), JSON/tekst (input_text).
|
|
* Wyjscie: merchant, date, total, items[] z proponowana kategoria (nazwa lub null).
|
|
*
|
|
* UWAGA: chat-completions z file_data NIE dostarcza tresci PDF (model halucynuje).
|
|
* Dziala dopiero Responses API + input_file. gpt-4o czyta gesty paragon lepiej niz mini.
|
|
* ponytail: jeden POST przez \Config\Services::curlrequest() — bez SDK.
|
|
*/
|
|
class ReceiptParser
|
|
{
|
|
private string $apiKey;
|
|
private string $model;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiKey = (string) env('OPEN_AI_API');
|
|
// o4-mini (model rozumujacy) czyta gesty paragon fiskalny najdokladniej — lepiej niz gpt-4o/4.1
|
|
// i tansze; poprawnie liczy rabaty (Opust). Podmiana przez env('openai.model').
|
|
$this->model = (string) (env('openai.model') ?: 'o4-mini');
|
|
|
|
if ($this->apiKey === '') {
|
|
throw new RuntimeException('Brak klucza OPEN_AI_API w .env.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string $filePath sciezka do wgranego pliku
|
|
* @param string $mime typ MIME pliku
|
|
* @param string[] $categories dozwolone nazwy kategorii wydatkow (do dopasowania)
|
|
*
|
|
* @return array{merchant:?string,date:?string,total:?float,items:array<int,array{name:string,qty:?float,amount:float,category:?string}>}
|
|
*/
|
|
public function parse(string $filePath, string $mime, array $categories): array
|
|
{
|
|
$content = file_get_contents($filePath);
|
|
if ($content === false) {
|
|
throw new RuntimeException('Nie udalo sie odczytac pliku paragonu.');
|
|
}
|
|
|
|
$parts = [['type' => 'input_text', 'text' => $this->prompt($categories)]];
|
|
|
|
if (str_starts_with($mime, 'image/')) {
|
|
$parts[] = ['type' => 'input_image', 'image_url' => 'data:' . $mime . ';base64,' . base64_encode($content)];
|
|
} elseif ($mime === 'application/pdf') {
|
|
$parts[] = [
|
|
'type' => 'input_file',
|
|
'filename' => 'paragon.pdf',
|
|
'file_data' => 'data:application/pdf;base64,' . base64_encode($content),
|
|
];
|
|
} else {
|
|
// JSON (lub inny tekst) — przekaz tresc do interpretacji.
|
|
$parts[] = ['type' => 'input_text', 'text' => "Dane paragonu (surowe):\n" . $content];
|
|
}
|
|
|
|
$payload = [
|
|
'model' => $this->model,
|
|
'text' => ['format' => ['type' => 'json_object']],
|
|
'input' => [['role' => 'user', 'content' => $parts]],
|
|
];
|
|
|
|
$client = \Config\Services::curlrequest(['timeout' => 120]);
|
|
$response = $client->post('https://api.openai.com/v1/responses', [
|
|
'headers' => [
|
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'body' => json_encode($payload),
|
|
'http_errors' => false,
|
|
]);
|
|
|
|
$status = $response->getStatusCode();
|
|
$body = (string) $response->getBody();
|
|
|
|
if ($status < 200 || $status >= 300) {
|
|
throw new RuntimeException('OpenAI API blad (HTTP ' . $status . '): ' . mb_substr($body, 0, 300));
|
|
}
|
|
|
|
$decoded = json_decode($body, true);
|
|
$text = $this->extractText($decoded);
|
|
if ($text === '') {
|
|
throw new RuntimeException('Nieoczekiwana odpowiedz OpenAI (brak tresci).');
|
|
}
|
|
|
|
$parsed = json_decode($text, true);
|
|
if (! is_array($parsed)) {
|
|
throw new RuntimeException('OpenAI zwrocilo niepoprawny JSON paragonu.');
|
|
}
|
|
|
|
return $this->normalize($parsed);
|
|
}
|
|
|
|
/**
|
|
* Wyciaga tekst z odpowiedzi Responses API (output_text lub output[].content[].text).
|
|
*/
|
|
private function extractText(?array $d): string
|
|
{
|
|
if (! is_array($d)) {
|
|
return '';
|
|
}
|
|
if (! empty($d['output_text']) && is_string($d['output_text'])) {
|
|
return $d['output_text'];
|
|
}
|
|
$txt = '';
|
|
foreach ($d['output'] ?? [] as $o) {
|
|
foreach ($o['content'] ?? [] as $c) {
|
|
if (isset($c['text']) && is_string($c['text'])) {
|
|
$txt .= $c['text'];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $txt;
|
|
}
|
|
|
|
private function prompt(array $categories): string
|
|
{
|
|
$list = implode(', ', $categories);
|
|
|
|
return <<<TXT
|
|
To jest polski PARAGON FISKALNY (kolumny tabeli pozycji: Nazwa, PTU, Ilosc, Cena, Wartosc).
|
|
Zwroc WYLACZNIE obiekt JSON o strukturze:
|
|
{"merchant":string|null,"date":"YYYY-MM-DD"|null,"total":number|null,"items":[{"name":string,"display":string,"qty":number|null,"amount":number,"category":string|null}]}
|
|
|
|
Zasady:
|
|
- "name" = nazwa DOKLADNIE jak na paragonie (skrocona, np. "WinogrJasneBezpLuz") — sluzy do mapowania.
|
|
- "display" = ladna, czytelna nazwa produktu po polsku dla czlowieka (np. "Winogrona jasne bezpestkowe").
|
|
- "amount" = kwota z kolumny WARTOSC (skrajnie prawa, laczna wartosc pozycji), NIE cena jednostkowa.
|
|
- Linia "Opust"/"Rabat" ze znakiem minus: odejmij ja od wartosci poprzedniej pozycji (podaj amount po rabacie).
|
|
- Ujmij pozycje z sekcji "Opakowania zwrotne"/kaucja, jesli wystepuja.
|
|
- POMIN wiersze podsumowan: Sprzedaz opodatkowana, PTU, Suma PLN, DO ZAPLATY.
|
|
- "date" = data zakupu z paragonu. "total" = kwota DO ZAPLATY.
|
|
- "category" dobierz TYLKO z tej listy (dokladna nazwa) albo null: {$list}
|
|
|
|
JESLI wejscie to surowy JSON z kasy fiskalnej (eParagon / JPK_KASA_PARAGON):
|
|
- WSZYSTKIE pola kwotowe (np. price, total, brutto, cena, wart, fiscalTotal, totalWithPacks, vatSale, amount) to liczby CALKOWITE w GROSZACH — PODZIEL kazda przez 100, aby dostac zlote (np. 369 => 3.69, 1365 => 13.65).
|
|
- "amount" pozycji = pole "total" danego wiersza sprzedazy (sellLine) / 100 (laczna wartosc pozycji, nie cena jednostkowa).
|
|
- "total" paragonu = kwota do zaplaty (totalWithPacks, w razie braku fiscalTotal) / 100.
|
|
- Reguła "/100" dotyczy TYLKO wejscia JSON; przy zdjeciu/PDF odczytuj kwoty jak sa.
|
|
|
|
Nie dodawaj zadnego tekstu poza obiektem JSON.
|
|
TXT;
|
|
}
|
|
|
|
/**
|
|
* Sanityzacja i rzutowanie typow z odpowiedzi modelu.
|
|
*/
|
|
private function normalize(array $p): array
|
|
{
|
|
$items = [];
|
|
foreach ($p['items'] ?? [] as $it) {
|
|
$name = trim((string) ($it['name'] ?? ''));
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$display = trim((string) ($it['display'] ?? ''));
|
|
$items[] = [
|
|
'name' => $name,
|
|
'display' => $display !== '' ? $display : $name,
|
|
'qty' => isset($it['qty']) && $it['qty'] !== null ? (float) $it['qty'] : null,
|
|
'amount' => round((float) ($it['amount'] ?? 0), 2),
|
|
'category' => isset($it['category']) && $it['category'] !== null && $it['category'] !== ''
|
|
? (string) $it['category'] : null,
|
|
];
|
|
}
|
|
|
|
$date = isset($p['date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $p['date']) ? (string) $p['date'] : null;
|
|
|
|
return [
|
|
'merchant' => isset($p['merchant']) && $p['merchant'] !== '' ? (string) $p['merchant'] : null,
|
|
'date' => $date,
|
|
'total' => isset($p['total']) && $p['total'] !== null ? round((float) $p['total'], 2) : null,
|
|
'items' => $items,
|
|
];
|
|
}
|
|
}
|