feat(catalog): add Amazon search and candidate import
This commit is contained in:
parent
97f3088c73
commit
de3c95b32a
|
|
@ -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<int, array<string, mixed>> */
|
||||
public array $amazonRows = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<?php
|
||||
namespace App\Services\Catalog;
|
||||
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIdentifier;
|
||||
use App\Models\ProductMedia;
|
||||
use App\Models\ProductOffer;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
|
@ -108,6 +110,130 @@ public function syncProduct(Product $product, array $options = []): array
|
|||
];
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
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<string,mixed>, 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<string, mixed> */
|
||||
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<int, array<string, mixed>> */
|
||||
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<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,75 @@
|
|||
Questa vista richiede un fornitore selezionato.
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border bg-white p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">Ricerca Amazon e memorizzazione candidati</div>
|
||||
<div class="mt-1 text-xs text-gray-500">Cerca nella Creators API, poi salva il risultato nel catalogo interno come articolo da verificare e provare sul campo.</div>
|
||||
</div>
|
||||
<div class="rounded-full px-3 py-1 text-xs font-semibold {{ $this->amazonConfigured ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700' }}">
|
||||
{{ $this->amazonConfigured ? 'Amazon Creators attiva' : 'Amazon Creators non configurata' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Query Amazon</span>
|
||||
<input type="text" wire:model.defer="amazonQuery" class="w-full rounded-lg border-gray-300" placeholder="Es. lampadina led e27 12w, citofono urmet, toner brother tn-2420" />
|
||||
</label>
|
||||
<div class="flex items-end">
|
||||
<button type="button" wire:click="searchAmazonCatalog" class="inline-flex items-center rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">Cerca su Amazon</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($amazonRows) > 0)
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
@foreach($amazonRows as $row)
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex gap-3">
|
||||
<div class="h-20 w-20 shrink-0 overflow-hidden rounded-lg border bg-white">
|
||||
@if(($row['image_url'] ?? '') !== '')
|
||||
<img src="{{ $row['image_url'] }}" alt="{{ $row['title'] }}" class="h-full w-full object-cover" />
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-[11px] text-slate-400">No image</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ $row['title'] }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">
|
||||
@if(($row['brand'] ?? '') !== '') Marca: {{ $row['brand'] }} · @endif
|
||||
ASIN: {{ $row['asin'] ?? 'n/d' }}
|
||||
@if(($row['ean'] ?? '') !== '') · EAN: {{ $row['ean'] }} @endif
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-500">
|
||||
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
|
||||
</div>
|
||||
@if(($row['availability'] ?? '') !== '')
|
||||
<div class="mt-1 text-xs text-slate-500">Disponibilità: {{ $row['availability'] }}</div>
|
||||
@endif
|
||||
@if(($row['existing_label'] ?? '') !== '')
|
||||
<div class="mt-2 rounded-lg bg-amber-50 px-2.5 py-1.5 text-[11px] font-medium text-amber-800">Già presente: {{ $row['existing_label'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@if(($row['referral_url'] ?? '') !== '')
|
||||
<a href="{{ $row['referral_url'] }}" target="_blank" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-slate-700 ring-1 ring-inset ring-slate-300 hover:bg-slate-50">Apri Amazon</a>
|
||||
@endif
|
||||
<button type="button" wire:click="saveAmazonCandidate('{{ $row['asin'] }}')" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">
|
||||
{{ ($row['existing_product_id'] ?? null) ? 'Aggiorna catalogo' : 'Salva candidato' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border bg-white p-4">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Cerca prodotto, codice interno o barcode</span>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user