diff --git a/app/Filament/Pages/Fornitore/ProdottiCatalogo.php b/app/Filament/Pages/Fornitore/ProdottiCatalogo.php index a3592da..001447b 100644 --- a/app/Filament/Pages/Fornitore/ProdottiCatalogo.php +++ b/app/Filament/Pages/Fornitore/ProdottiCatalogo.php @@ -7,9 +7,13 @@ use App\Models\ProductIdentifier; use App\Models\ProductOffer; use App\Models\User; +use App\Services\Catalog\AmazonCreatorsApiService; +use App\Services\Catalog\AmazonCreatorsCatalogSyncService; use BackedEnum; +use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Support\Facades\Auth; +use RuntimeException; use UnitEnum; class ProdottiCatalogo extends Page @@ -38,6 +42,13 @@ class ProdottiCatalogo extends Page public string $search = ''; + public string $amazonQuery = ''; + + public bool $amazonConfigured = false; + + /** @var array> */ + public array $amazonRows = []; + /** @var array> */ public array $rows = []; @@ -60,6 +71,7 @@ public function mount(): void $this->fornitoreId = (int) $fornitore->id; $this->fornitoreLabel = $this->getFornitoreLabel($fornitore); + $this->amazonConfigured = app(AmazonCreatorsApiService::class)->isConfigured(); $this->refreshRows(); } @@ -122,6 +134,81 @@ public function refreshRows(): void })->all(); } + public function searchAmazonCatalog(): void + { + if (! $this->amazonConfigured) { + Notification::make() + ->title('Amazon Creators API non configurata') + ->body('Valorizza prima le variabili AMAZON_CREATORS_* in ambiente.') + ->danger() + ->send(); + + return; + } + + $query = trim($this->amazonQuery); + if ($query === '') { + Notification::make()->title('Inserisci una query Amazon')->warning()->send(); + + return; + } + + try { + $this->amazonRows = app(AmazonCreatorsCatalogSyncService::class) + ->searchCatalog($query, 8); + } catch (RuntimeException $e) { + $this->amazonRows = []; + + Notification::make()->title('Ricerca Amazon non riuscita')->body($e->getMessage())->danger()->send(); + + return; + } + + if ($this->amazonRows === []) { + Notification::make()->title('Nessun risultato Amazon')->warning()->send(); + } + } + + public function saveAmazonCandidate(string $asin): void + { + $asin = trim($asin); + $row = collect($this->amazonRows)->first(fn(array $item): bool => (string) ($item['asin'] ?? '') === $asin); + if (! is_array($row) || ! is_array($row['item_payload'] ?? null)) { + Notification::make()->title('Risultato Amazon non disponibile')->danger()->send(); + + return; + } + + $fornitore = $this->fornitoreId ? Fornitore::query()->find((int) $this->fornitoreId) : null; + if (! $fornitore instanceof Fornitore) { + Notification::make()->title('Fornitore non trovato')->danger()->send(); + + return; + } + + try { + $result = app(AmazonCreatorsCatalogSyncService::class) + ->importItemForSupplier($fornitore, $row['item_payload'], ['source' => 'amazon_catalog_search_ui']); + } catch (RuntimeException $e) { + Notification::make()->title('Import Amazon non riuscito')->body($e->getMessage())->danger()->send(); + + return; + } + + $product = $result['product'] ?? null; + + Notification::make() + ->title(! empty($result['created']) ? 'Prodotto Amazon memorizzato' : 'Prodotto Amazon aggiornato') + ->body($product instanceof Product + ? 'Articolo salvato nel catalogo come candidato da verificare: ' . ((string) ($product->internal_code ?? '') !== '' ? $product->internal_code . ' · ' : '') . (string) ($product->name ?? '') + : 'Import completato.') + ->success() + ->send(); + + $this->refreshRows(); + $this->searchAmazonCatalog(); + } + public function getLavorazioniUrl(): string { return LavorazioniOperative::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament'); diff --git a/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php b/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php index 44bad3c..1561e89 100644 --- a/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php +++ b/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php @@ -1,10 +1,12 @@ > */ + public function searchCatalog(string $query, int $limit = 8, array $options = []): array + { + $query = trim($query); + if ($query === '') { + throw new RuntimeException('Inserisci una query Amazon da cercare.'); + } + + $response = $this->apiService->searchItems($query, [ + 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), + ]); + + return $this->extractItems($response) + ->take(max(1, $limit)) + ->map(function (array $item): array { + $asin = $this->extractAsin($item); + $ean = $this->extractEan($item); + $existingProduct = $this->findExistingProduct($asin, $ean); + $detailUrl = $this->extractDetailPageUrl($item); + $associateTag = $this->apiService->getAssociateTag(); + + return [ + 'asin' => $asin, + 'ean' => $ean, + 'title' => $this->extractTitle($item) ?: 'Prodotto Amazon', + 'brand' => $this->extractBrand($item), + 'price_amount' => $this->extractPriceAmount($item), + 'price_compare_at' => $this->extractCompareAtAmount($item), + 'currency' => $this->extractCurrency($item) ?? 'EUR', + 'availability' => $this->extractAvailability($item) ?? '', + 'detail_url' => $detailUrl, + 'referral_url' => $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : null, + 'image_url' => $this->extractPrimaryImageUrl($item), + 'existing_product_id' => $existingProduct?->id, + 'existing_label' => $existingProduct instanceof Product + ? trim((string) ($existingProduct->internal_code ?? '') . ' · ' . (string) ($existingProduct->name ?? '')) + : '', + 'item_payload' => $item, + ]; + }) + ->values() + ->all(); + } + + /** @return array{product: Product, sync: array, created: bool} */ + public function importItemForSupplier(Fornitore $fornitore, array $item, array $options = []): array + { + $asin = $this->extractAsin($item); + if ($asin === null) { + throw new RuntimeException('Il risultato Amazon selezionato non contiene un ASIN valido.'); + } + + $ean = $this->extractEan($item); + $title = $this->extractTitle($item) ?: 'Prodotto Amazon'; + $brand = $this->extractBrand($item); + $product = $this->findExistingProduct($asin, $ean); + $created = false; + + if (! $product instanceof Product) { + $product = Product::query()->create([ + 'default_fornitore_id' => (int) $fornitore->id, + 'type' => 'product', + 'canonical_key' => $this->buildCanonicalKey($title, $asin, $ean), + 'name' => $title, + 'brand' => $brand, + 'model' => null, + 'description' => null, + 'track_serials' => false, + 'is_active' => true, + 'meta' => [ + 'source' => 'amazon_creators_api', + 'catalog' => [ + 'publication_mode' => 'internal_only', + ], + 'verification' => [ + 'status' => 'pending_review', + 'channel' => 'amazon_catalog_search_ui', + 'updated_at' => now()->toIso8601String(), + ], + 'amazon' => [ + 'asin' => $asin, + 'ean' => $ean, + 'title' => $title, + ], + ], + ]); + $created = true; + } else { + $meta = is_array($product->meta ?? null) ? $product->meta : []; + $meta['source'] = $meta['source'] ?? 'amazon_creators_api'; + $meta['verification'] = [ + 'status' => 'pending_review', + 'channel' => 'amazon_catalog_search_ui', + 'updated_at' => now()->toIso8601String(), + ]; + $meta['amazon'] = array_filter([ + 'asin' => $asin, + 'ean' => $ean, + 'title' => $title, + ], static fn(mixed $value): bool => $value !== null && $value !== ''); + + $product->fill([ + 'default_fornitore_id' => $product->default_fornitore_id ?: (int) $fornitore->id, + 'name' => trim((string) $product->name) !== '' ? $product->name : $title, + 'brand' => trim((string) ($product->brand ?? '')) !== '' ? $product->brand : $brand, + 'meta' => $meta, + ]); + $product->save(); + } + + $sync = $this->syncProduct($product, [ + 'item' => $item, + 'source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'), + 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), + 'associate_tag'=> (string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? '')), + ]); + + return [ + 'product' => $product->fresh(), + 'sync' => $sync, + 'created' => $created, + ]; + } + /** @return array */ private function resolveItemPayload(Product $product, array $options): array { @@ -142,6 +268,27 @@ private function resolveItemPayload(Product $product, array $options): array return $this->pickFirstItem($response) ?? []; } + /** @return Collection> */ + private function extractItems(array $payload): Collection + { + foreach ([ + 'items', + 'ItemsResult.Items', + 'SearchResult.Items', + 'GetItemsResult.Items', + 'data.items', + 'data.searchItems.items', + 'data.getItems.items', + ] as $path) { + $items = data_get($payload, $path); + if (is_array($items)) { + return collect($items)->filter('is_array')->values(); + } + } + + return collect(isset($payload['asin']) || isset($payload['ASIN']) ? [$payload] : []); + } + /** @return array|null */ private function pickFirstItem(array $payload): ?array { @@ -205,6 +352,18 @@ private function extractTitle(array $item): ?string return null; } + private function extractBrand(array $item): ?string + { + foreach (['brand', 'Brand', 'ItemInfo.ByLineInfo.Brand.DisplayValue', 'itemInfo.byLineInfo.brand.displayValue'] as $path) { + $value = trim((string) data_get($item, $path, '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + private function extractDetailPageUrl(array $item): ?string { foreach (['detailPageURL', 'DetailPageURL', 'detailPageUrl', 'url'] as $path) { @@ -354,6 +513,50 @@ private function buildSearchQuery(Product $product): string return Str::limit(implode(' ', $parts), 120, ''); } + private function findExistingProduct(?string $asin, ?string $ean): ?Product + { + $codes = array_values(array_filter([ + $this->normalizeCode($asin), + $this->normalizeCode($ean), + ])); + + if ($codes === []) { + return null; + } + + $productId = ProductIdentifier::query() + ->whereIn('normalized_code', $codes) + ->orderByDesc('is_primary') + ->value('product_id'); + + return $productId ? Product::query()->find((int) $productId) : null; + } + + private function buildCanonicalKey(string $title, ?string $asin, ?string $ean): string + { + $base = Str::slug(Str::limit($title, 80, '')); + $seed = $this->normalizeCode($asin) ?: $this->normalizeCode($ean) ?: Str::upper(Str::random(8)); + $key = Str::limit(($base !== '' ? $base : 'amazon-item') . '-' . Str::lower($seed), 190, ''); + + if (! Product::query()->where('canonical_key', $key)->exists()) { + return $key; + } + + return Str::limit($key . '-' . Str::lower(Str::random(4)), 190, ''); + } + + private function normalizeCode(?string $value): ?string + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + + $normalized = strtoupper(preg_replace('/[^A-Z0-9]+/', '', $value) ?? ''); + + return $normalized !== '' ? $normalized : null; + } + private function upsertIdentifier(Product $product, string $type, string $value, string $source, bool $isPrimary): bool { $value = trim($value); @@ -367,9 +570,9 @@ private function upsertIdentifier(Product $product, string $type, string $value, } $identifier = ProductIdentifier::query()->firstOrNew([ - 'product_id' => (int) $product->id, - 'code_type' => $type, - 'normalized_code' => $normalized, + 'product_id' => (int) $product->id, + 'code_type' => $type, + 'normalized_code' => $normalized, ]); $created = ! $identifier->exists; @@ -408,4 +611,4 @@ private function upsertRemoteMedia(Product $product, string $url, string $title) return $created; } -} \ No newline at end of file +} diff --git a/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php b/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php index 17a982c..b5ac16c 100644 --- a/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php +++ b/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php @@ -24,6 +24,75 @@ Questa vista richiede un fornitore selezionato. @else +
+
+
+
Ricerca Amazon e memorizzazione candidati
+
Cerca nella Creators API, poi salva il risultato nel catalogo interno come articolo da verificare e provare sul campo.
+
+
+ {{ $this->amazonConfigured ? 'Amazon Creators attiva' : 'Amazon Creators non configurata' }} +
+
+ +
+ +
+ +
+
+ + @if(count($amazonRows) > 0) +
+ @foreach($amazonRows as $row) +
+
+
+ @if(($row['image_url'] ?? '') !== '') + {{ $row['title'] }} + @else +
No image
+ @endif +
+
+
{{ $row['title'] }}
+
+ @if(($row['brand'] ?? '') !== '') Marca: {{ $row['brand'] }} · @endif + ASIN: {{ $row['asin'] ?? 'n/d' }} + @if(($row['ean'] ?? '') !== '') · EAN: {{ $row['ean'] }} @endif +
+
+ Prezzo: {{ ($row['price_amount'] ?? null) !== null ? number_format((float) $row['price_amount'], 2, ',', '.') . ' ' . ($row['currency'] ?? 'EUR') : 'n/d' }} + @if(($row['price_compare_at'] ?? null) !== null) + · listino {{ number_format((float) $row['price_compare_at'], 2, ',', '.') . ' ' . ($row['currency'] ?? 'EUR') }} + @endif +
+ @if(($row['availability'] ?? '') !== '') +
Disponibilità: {{ $row['availability'] }}
+ @endif + @if(($row['existing_label'] ?? '') !== '') +
Già presente: {{ $row['existing_label'] }}
+ @endif +
+
+ +
+ @if(($row['referral_url'] ?? '') !== '') + Apri Amazon + @endif + +
+
+ @endforeach +
+ @endif +
+