*/ public function syncProduct(Product $product, array $options = []): array { $dryRun = (bool) ($options['dry_run'] ?? false); $item = $this->resolveItemPayload($product, $options); if (! is_array($item) || $item === []) { throw new RuntimeException('Nessun item Amazon trovato per il prodotto selezionato.'); } $asin = $this->extractAsin($item); if ($asin === null) { throw new RuntimeException('La risposta Amazon non contiene un ASIN utilizzabile.'); } $title = $this->extractTitle($item) ?: (string) ($product->name ?? 'Prodotto Amazon'); $marketplace = trim((string) ($options['marketplace'] ?? $this->apiService->getMarketplace())) ?: $this->apiService->getMarketplace(); $associateTag = trim((string) ($options['associate_tag'] ?? $this->apiService->getAssociateTag() ?? '')) ?: null; $detailUrl = $this->extractDetailPageUrl($item); $referralUrl = $detailUrl !== null ? $this->appendAssociateTag($detailUrl, $associateTag) : $this->productOfferService->buildAmazonReferralUrl($product, $associateTag, $this->mapMarketplaceToLocale($marketplace)); $price = $this->extractPriceAmount($item); $compareAt = $this->extractCompareAtAmount($item); $currency = $this->extractCurrency($item) ?? 'EUR'; $availability = $this->extractAvailability($item) ?? 'catalog_synced'; $externalId = $asin; $ean = $this->extractEan($item); $imageUrl = $this->extractPrimaryImageUrl($item); $payloadSummary = [ 'asin' => $asin, 'title' => $title, 'marketplace' => $marketplace, 'detail_url' => $detailUrl, 'image_url' => $imageUrl, 'price_amount' => $price, 'currency' => $currency, ]; $identifiersCreated = 0; $mediaCreated = 0; $offer = null; if (! $dryRun) { $identifiersCreated += $this->upsertIdentifier($product, 'asin', $asin, 'amazon_creators_api', true) ? 1 : 0; if ($ean !== null) { $identifiersCreated += $this->upsertIdentifier($product, 'ean', $ean, 'amazon_creators_api', false) ? 1 : 0; } if ($imageUrl !== null) { $mediaCreated += $this->upsertRemoteMedia($product, $imageUrl, $title) ? 1 : 0; } $offer = $this->productOfferService->syncOffer($product, [ 'source_type' => 'amazon_creators_api', 'source_name' => 'Amazon Creators API', 'external_product_id' => $externalId, 'title' => $title, 'currency' => $currency, 'price_amount' => $price, 'price_compare_at' => $compareAt, 'availability' => $availability, 'external_url' => $detailUrl, 'referral_url' => $referralUrl, 'referral_code' => $associateTag, 'is_internal' => false, 'is_active' => true, 'meta' => [ 'marketplace' => $marketplace, 'sync_source' => (string) ($options['source'] ?? 'api'), 'payload' => $payloadSummary, ], ]); } return [ 'asin' => $asin, 'title' => $title, 'detail_url' => $detailUrl, 'referral_url' => $referralUrl, 'image_url' => $imageUrl, 'price_amount' => $price, 'price_compare_at' => $compareAt, 'currency' => $currency, 'availability' => $availability, 'ean' => $ean, 'identifiers_created' => $identifiersCreated, 'media_created' => $mediaCreated, 'offer_id' => $offer instanceof ProductOffer ? (int) $offer->id : null, 'source' => (string) ($options['source'] ?? 'api'), 'dry_run' => $dryRun, ]; } /** @return array> */ 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 { if (is_array($options['item'] ?? null)) { return $options['item']; } if (is_array($options['response'] ?? null)) { $candidate = $this->pickFirstItem((array) $options['response']); return is_array($candidate) ? $candidate : []; } $asin = trim((string) ($options['asin'] ?? $this->findIdentifierValue($product, ['asin']) ?? '')); if ($asin !== '') { $response = $this->apiService->getItems([$asin], [ 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), ]); return $this->pickFirstItem($response) ?? []; } $query = trim((string) ($options['query'] ?? $this->findIdentifierValue($product, ['ean']) ?? $this->buildSearchQuery($product))); if ($query === '') { return []; } $response = $this->apiService->searchItems($query, [ 'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()), ]); 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 { 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) && isset($items[0]) && is_array($items[0])) { return $items[0]; } } return isset($payload['asin']) || isset($payload['ASIN']) ? $payload : null; } private function extractAsin(array $item): ?string { foreach (['asin', 'ASIN', 'itemId', 'ItemId'] as $path) { $value = trim((string) data_get($item, $path, '')); if ($value !== '') { return $value; } } return null; } private function extractEan(array $item): ?string { foreach ([ 'ean', 'EAN', 'ExternalIds.EANs.DisplayValues.0', 'externalIds.eans.0', 'identifiers.ean', ] as $path) { $value = preg_replace('/\s+/', '', trim((string) data_get($item, $path, ''))); if (is_string($value) && $value !== '') { return $value; } } return null; } private function extractTitle(array $item): ?string { foreach (['title', 'Title', 'ItemInfo.Title.DisplayValue', 'itemInfo.title.displayValue'] as $path) { $value = trim((string) data_get($item, $path, '')); if ($value !== '') { return $value; } } 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) { $value = trim((string) data_get($item, $path, '')); if ($value !== '') { return $value; } } return null; } private function extractPrimaryImageUrl(array $item): ?string { foreach ([ 'Images.Primary.Large.URL', 'Images.Primary.Medium.URL', 'Images.Primary.Small.URL', 'images.primary.large.url', 'images.primary.medium.url', 'images.0.url', 'image', ] as $path) { $value = trim((string) data_get($item, $path, '')); if ($value !== '') { return $value; } } return null; } private function extractPriceAmount(array $item): ?float { foreach ([ 'Offers.Listings.0.Price.Amount', 'Offers.Summaries.0.LowestPrice.Amount', 'offers.listings.0.price.amount', 'offers.summaries.0.lowestPrice.amount', 'price.amount', ] as $path) { $value = data_get($item, $path); if (is_numeric($value)) { return round((float) $value, 2); } } return null; } private function extractCompareAtAmount(array $item): ?float { foreach ([ 'Offers.Listings.0.SavingBasis.Amount', 'offers.listings.0.savingBasis.amount', 'price.compareAt.amount', ] as $path) { $value = data_get($item, $path); if (is_numeric($value)) { return round((float) $value, 2); } } return null; } private function extractCurrency(array $item): ?string { foreach ([ 'Offers.Listings.0.Price.Currency', 'Offers.Summaries.0.LowestPrice.Currency', 'offers.listings.0.price.currency', 'offers.summaries.0.lowestPrice.currency', 'price.currency', ] as $path) { $value = trim((string) data_get($item, $path, '')); if ($value !== '') { return strtoupper($value); } } return null; } private function extractAvailability(array $item): ?string { foreach ([ 'Offers.Listings.0.Availability.Message', 'offers.listings.0.availability.message', 'availability', ] as $path) { $value = trim((string) data_get($item, $path, '')); if ($value !== '') { return $value; } } return null; } private function appendAssociateTag(string $url, ?string $associateTag): string { $associateTag = $associateTag !== null ? trim($associateTag) : ''; if ($associateTag === '') { return $url; } $separator = str_contains($url, '?') ? '&' : '?'; return str_contains($url, 'tag=') ? $url : ($url . $separator . 'tag=' . rawurlencode($associateTag)); } private function mapMarketplaceToLocale(string $marketplace): string { return match (Str::lower(trim($marketplace))) { 'www.amazon.de' => 'de', 'www.amazon.fr' => 'fr', 'www.amazon.es' => 'es', 'www.amazon.co.uk' => 'uk', default => 'it', }; } /** @param array $types */ private function findIdentifierValue(Product $product, array $types): ?string { $value = ProductIdentifier::query() ->where('product_id', (int) $product->id) ->whereIn('code_type', $types) ->orderByDesc('is_primary') ->orderBy('id') ->value('code_value'); $value = trim((string) $value); return $value !== '' ? $value : null; } private function buildSearchQuery(Product $product): string { $parts = array_values(array_unique(array_filter([ trim((string) ($product->brand ?? '')), trim((string) ($product->model ?? '')), trim((string) ($product->name ?? '')), ], static fn(string $value): bool => $value !== ''))); 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); if ($value === '') { return false; } $normalized = strtoupper(preg_replace('/[^A-Z0-9]+/', '', $value) ?? ''); if ($normalized === '') { return false; } $identifier = ProductIdentifier::query()->firstOrNew([ 'product_id' => (int) $product->id, 'code_type' => $type, 'normalized_code' => $normalized, ]); $created = ! $identifier->exists; $identifier->code_role = $type; $identifier->code_value = $value; $identifier->source = $source; $identifier->source_reference = $value; $identifier->is_primary = $isPrimary; $identifier->payload = ['provider' => 'amazon']; $identifier->save(); return $created; } private function upsertRemoteMedia(Product $product, string $url, string $title): bool { $url = trim($url); if ($url === '' || ! str_starts_with($url, 'http')) { return false; } $media = ProductMedia::query()->firstOrNew([ 'product_id' => (int) $product->id, 'media_type' => 'image', 'disk' => 'remote-url', 'path' => $url, ]); $created = ! $media->exists; $media->title = $title; $media->meta = [ 'provider' => 'amazon_creators_api', 'remote' => true, ]; $media->save(); return $created; } }