Files
Vasyka fca4f75e9c feat: OCR invoice import via Claude Vision
Upload an invoice photo → Claude extracts {supplier_name, date, currency,
items, total} as JSON → auto-create a draft Purchase + PurchaseItems →
redirect to edit so the user reviews before confirming/receiving.

OcrInvoiceService:
- Validates supported MIME (jpg/png/webp/gif)
- Reads tenant Claude key (settings.ai.claude_key) — friendly error if missing
- Calls /v1/messages with image content block + structured-output system prompt
- Tolerant parser: strips ```json fences, falls back to first {…} block
- normalize(): computes per-item total when absent, fills overall total
- All return shapes: {ok:bool, data?, error?, raw?, tokens?}

Filament:
- "Import factură (OCR)" header action on Purchases list
- Image file upload → service → matches Supplier by case-insensitive name
  (notes the unmapped name if no match) → creates draft Purchase + items →
  redirects to the Edit page

Tests (6 new):
- clean JSON parses; markdown fences stripped; malformed → graceful error;
  missing key → friendly message + no HTTP; unsupported MIME rejected;
  item total computed when missing

Full suite: 123 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 06:00:45 +00:00

147 lines
5.1 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Services\Ai\OcrInvoiceService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class OcrInvoiceTest extends TestCase
{
use RefreshDatabase;
public function test_extract_parses_clean_json_response(): void
{
$this->makeCtx(claudeKey: 'sk-fake');
Http::fake(['api.anthropic.com/*' => Http::response([
'content' => [['type' => 'text', 'text' => json_encode([
'supplier_name' => 'Acme Parts SRL',
'date' => '2026-05-15',
'currency' => 'MDL',
'items' => [
['name' => 'Filtru ulei', 'qty' => 2, 'unit_price' => 75, 'total' => 150],
['name' => 'Filtru aer', 'qty' => 1, 'unit_price' => 90, 'total' => 90],
],
'total' => 240,
])]],
'usage' => ['input_tokens' => 1500, 'output_tokens' => 120],
])]);
$img = $this->fakeImage();
$r = app(OcrInvoiceService::class)->extract($img);
$this->assertTrue($r['ok']);
$this->assertEquals('Acme Parts SRL', $r['data']['supplier_name']);
$this->assertEquals('2026-05-15', $r['data']['date']);
$this->assertCount(2, $r['data']['items']);
$this->assertEquals(240.0, $r['data']['total']);
@unlink($img);
}
public function test_extract_strips_markdown_fences(): void
{
$this->makeCtx(claudeKey: 'sk-fake');
$payload = json_encode(['supplier_name' => 'X', 'items' => [
['name' => 'Y', 'qty' => 1, 'unit_price' => 50],
]]);
Http::fake(['api.anthropic.com/*' => Http::response([
'content' => [['type' => 'text', 'text' => "```json\n{$payload}\n```"]],
'usage' => ['input_tokens' => 100, 'output_tokens' => 30],
])]);
$img = $this->fakeImage();
$r = app(OcrInvoiceService::class)->extract($img);
$this->assertTrue($r['ok']);
$this->assertEquals('X', $r['data']['supplier_name']);
@unlink($img);
}
public function test_extract_handles_malformed_response(): void
{
$this->makeCtx(claudeKey: 'sk-fake');
Http::fake(['api.anthropic.com/*' => Http::response([
'content' => [['type' => 'text', 'text' => 'this is not json at all']],
'usage' => ['input_tokens' => 50, 'output_tokens' => 10],
])]);
$img = $this->fakeImage();
$r = app(OcrInvoiceService::class)->extract($img);
$this->assertFalse($r['ok']);
$this->assertStringContainsString('ne-parsabil', $r['error']);
@unlink($img);
}
public function test_missing_api_key_returns_friendly_error(): void
{
$this->makeCtx(claudeKey: null);
$img = $this->fakeImage();
$r = app(OcrInvoiceService::class)->extract($img);
$this->assertFalse($r['ok']);
$this->assertStringContainsString('Setări', $r['error']);
Http::assertNothingSent();
@unlink($img);
}
public function test_unsupported_mime_rejected(): void
{
$this->makeCtx(claudeKey: 'sk-fake');
$path = tempnam(sys_get_temp_dir(), 'ocr');
file_put_contents($path, "%PDF-not really");
$r = app(OcrInvoiceService::class)->extract($path, 'application/pdf');
$this->assertFalse($r['ok']);
$this->assertStringContainsString('Tip fișier', $r['error']);
@unlink($path);
}
public function test_normalize_computes_item_total_when_missing(): void
{
$this->makeCtx(claudeKey: 'sk-fake');
Http::fake(['api.anthropic.com/*' => Http::response([
'content' => [['type' => 'text', 'text' => json_encode([
'items' => [['name' => 'A', 'qty' => 3, 'unit_price' => 25]],
])]],
'usage' => ['input_tokens' => 1, 'output_tokens' => 1],
])]);
$img = $this->fakeImage();
$r = app(OcrInvoiceService::class)->extract($img);
$this->assertEquals(75.0, $r['data']['items'][0]['total']);
$this->assertEquals(75.0, $r['data']['total']);
@unlink($img);
}
private function makeCtx(?string $claudeKey): Company
{
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
$company = Company::create([
'plan_id' => $plan->id, 'slug' => 'ocr-' . uniqid(),
'name' => 'OCR Co', 'status' => 'active',
'settings' => ['ai' => ['claude_key' => $claudeKey]],
]);
app(TenantManager::class)->setCurrent($company);
return $company;
}
private function fakeImage(): string
{
// Minimal 1x1 PNG so mime_content_type returns image/png.
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==');
$path = tempnam(sys_get_temp_dir(), 'ocr') . '.png';
file_put_contents($path, $bytes);
return $path;
}
}