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} */ 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 << 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, ]; } }