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>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user