896 lines
34 KiB
PHP
Executable File
896 lines
34 KiB
PHP
Executable File
<?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\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class AmazonCreatorsCatalogSyncService
|
|
{
|
|
public function __construct(
|
|
private readonly AmazonCreatorsApiService $apiService,
|
|
private readonly ProductOfferService $productOfferService,
|
|
) {
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
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);
|
|
$upc = $this->extractUpc($item);
|
|
$manufacturerCode = $this->extractManufacturerPartNumber($item);
|
|
$imageUrls = $this->extractImageUrls($item);
|
|
$imageUrl = $imageUrls[0] ?? null;
|
|
$payloadSummary = $this->buildAmazonSnapshot($item, [
|
|
'asin' => $asin,
|
|
'ean' => $ean,
|
|
'upc' => $upc,
|
|
'manufacturer_code' => $manufacturerCode,
|
|
'title' => $title,
|
|
'marketplace' => $marketplace,
|
|
'associate_tag' => $associateTag,
|
|
'detail_url' => $detailUrl,
|
|
'referral_url' => $referralUrl,
|
|
'image_url' => $imageUrl,
|
|
'image_urls' => $imageUrls,
|
|
'price_amount' => $price,
|
|
'price_compare_at' => $compareAt,
|
|
'currency' => $currency,
|
|
'availability' => $availability,
|
|
'search_query' => trim((string) ($options['search_query'] ?? '')),
|
|
]);
|
|
|
|
$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 ($upc !== null) {
|
|
$identifiersCreated += $this->upsertIdentifier($product, 'upc', $upc, 'amazon_creators_api', false) ? 1 : 0;
|
|
}
|
|
if ($manufacturerCode !== null) {
|
|
$identifiersCreated += $this->upsertIdentifier($product, 'mpn', $manufacturerCode, 'amazon_creators_api', false) ? 1 : 0;
|
|
}
|
|
|
|
foreach (array_values(array_slice($imageUrls, 0, 12)) as $index => $remoteUrl) {
|
|
$mediaCreated += $this->upsertRemoteMedia(
|
|
$product,
|
|
$remoteUrl,
|
|
$title,
|
|
$index,
|
|
[
|
|
'provider' => 'amazon_creators_api',
|
|
'role' => $index === 0 ? 'primary' : 'gallery',
|
|
]
|
|
) ? 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,
|
|
'image_urls' => $imageUrls,
|
|
'price_amount' => $price,
|
|
'price_compare_at' => $compareAt,
|
|
'currency' => $currency,
|
|
'availability' => $availability,
|
|
'ean' => $ean,
|
|
'upc' => $upc,
|
|
'manufacturer_code' => $manufacturerCode,
|
|
'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<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);
|
|
$upc = $this->extractUpc($item);
|
|
$manufacturerCode = $this->extractManufacturerPartNumber($item);
|
|
$existingProduct = $this->findExistingProduct($asin, $ean, $upc, $manufacturerCode);
|
|
$detailUrl = $this->extractDetailPageUrl($item);
|
|
$associateTag = $this->apiService->getAssociateTag();
|
|
|
|
return [
|
|
'asin' => $asin,
|
|
'ean' => $ean,
|
|
'upc' => $upc,
|
|
'manufacturer_code' => $manufacturerCode,
|
|
'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
|
|
{
|
|
$item = $this->enrichItem($item, $options);
|
|
|
|
$asin = $this->extractAsin($item);
|
|
if ($asin === null) {
|
|
throw new RuntimeException('Il risultato Amazon selezionato non contiene un ASIN valido.');
|
|
}
|
|
|
|
$ean = $this->extractEan($item);
|
|
$upc = $this->extractUpc($item);
|
|
$manufacturerCode = $this->extractManufacturerPartNumber($item);
|
|
$title = $this->extractTitle($item) ?: 'Prodotto Amazon';
|
|
$brand = $this->extractBrand($item);
|
|
$model = $this->extractModel($item);
|
|
$description = $this->buildDescriptionFromAmazon($item);
|
|
$product = $this->findExistingProduct($asin, $ean, $upc, $manufacturerCode);
|
|
$created = false;
|
|
|
|
$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) : null;
|
|
$snapshot = $this->buildAmazonSnapshot($item, [
|
|
'asin' => $asin,
|
|
'ean' => $ean,
|
|
'upc' => $upc,
|
|
'manufacturer_code' => $manufacturerCode,
|
|
'title' => $title,
|
|
'brand' => $brand,
|
|
'model' => $model,
|
|
'marketplace' => $marketplace,
|
|
'associate_tag' => $associateTag,
|
|
'detail_url' => $detailUrl,
|
|
'referral_url' => $referralUrl,
|
|
'search_query' => trim((string) ($options['search_query'] ?? '')),
|
|
'sync_source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'),
|
|
]);
|
|
|
|
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' => $model,
|
|
'description' => $description,
|
|
'track_serials' => false,
|
|
'is_active' => true,
|
|
'meta' => [
|
|
'source' => 'amazon_creators_api',
|
|
'catalog' => [
|
|
'publication_mode' => 'internal_only',
|
|
'public_showcase' => false,
|
|
],
|
|
'verification' => [
|
|
'status' => 'pending_review',
|
|
'channel' => 'amazon_catalog_search_ui',
|
|
'updated_at' => now()->toIso8601String(),
|
|
],
|
|
'amazon' => $snapshot,
|
|
],
|
|
]);
|
|
$created = true;
|
|
} else {
|
|
$meta = is_array($product->meta ?? null) ? $product->meta : [];
|
|
$meta['source'] = $meta['source'] ?? 'amazon_creators_api';
|
|
$meta['catalog'] = array_replace([
|
|
'publication_mode' => 'internal_only',
|
|
'public_showcase' => false,
|
|
], is_array($meta['catalog'] ?? null) ? $meta['catalog'] : []);
|
|
$meta['verification'] = [
|
|
'status' => 'pending_review',
|
|
'channel' => 'amazon_catalog_search_ui',
|
|
'updated_at' => now()->toIso8601String(),
|
|
];
|
|
$meta['amazon'] = array_filter(array_replace(
|
|
is_array($meta['amazon'] ?? null) ? $meta['amazon'] : [],
|
|
$snapshot
|
|
), 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,
|
|
'model' => trim((string) ($product->model ?? '')) !== '' ? $product->model : $model,
|
|
'description' => trim((string) ($product->description ?? '')) !== '' ? $product->description : $description,
|
|
'meta' => $meta,
|
|
]);
|
|
$product->save();
|
|
}
|
|
|
|
$sync = $this->syncProduct($product, [
|
|
'item' => $item,
|
|
'source' => (string) ($options['source'] ?? 'amazon_catalog_search_ui'),
|
|
'marketplace' => $marketplace,
|
|
'associate_tag' => (string) ($associateTag ?? ''),
|
|
'search_query' => trim((string) ($options['search_query'] ?? '')),
|
|
]);
|
|
|
|
return [
|
|
'product' => $product->fresh(),
|
|
'sync' => $sync,
|
|
'created' => $created,
|
|
];
|
|
}
|
|
|
|
/** @param array<int, array<string, mixed>> $rows
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function importSearchResultsForSupplier(Fornitore $fornitore, array $rows, array $options = []): array
|
|
{
|
|
$created = 0;
|
|
$updated = 0;
|
|
$skipped = 0;
|
|
$errors = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$item = is_array($row['item_payload'] ?? null) ? $row['item_payload'] : null;
|
|
if (! is_array($item) || $item === []) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$result = $this->importItemForSupplier($fornitore, $item, [
|
|
'source' => (string) ($options['source'] ?? 'amazon_catalog_search_bulk_ui'),
|
|
'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()),
|
|
'associate_tag' => (string) ($options['associate_tag'] ?? ($this->apiService->getAssociateTag() ?? '')),
|
|
'search_query' => trim((string) ($options['search_query'] ?? '')),
|
|
]);
|
|
|
|
if (! empty($result['created'])) {
|
|
$created++;
|
|
} else {
|
|
$updated++;
|
|
}
|
|
} catch (RuntimeException $e) {
|
|
$errors[] = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
return [
|
|
'created' => $created,
|
|
'updated' => $updated,
|
|
'skipped' => $skipped,
|
|
'errors' => array_values(array_unique($errors)),
|
|
'processed' => $created + $updated,
|
|
];
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
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<int, array<string, mixed>> */
|
|
private function extractItems(array $payload): Collection
|
|
{
|
|
foreach ([
|
|
'items',
|
|
'itemsResult.items',
|
|
'searchResult.items',
|
|
'getItemsResult.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(static fn(mixed $item): bool => is_array($item))->values();
|
|
}
|
|
}
|
|
|
|
return collect(isset($payload['asin']) || isset($payload['ASIN']) ? [$payload] : []);
|
|
}
|
|
|
|
/** @return array<string, mixed>|null */
|
|
private function pickFirstItem(array $payload): ?array
|
|
{
|
|
foreach ([
|
|
'items',
|
|
'itemsResult.items',
|
|
'searchResult.items',
|
|
'getItemsResult.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',
|
|
'itemInfo.externalIds.eans.displayValues.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 extractUpc(array $item): ?string
|
|
{
|
|
foreach ([
|
|
'UPC',
|
|
'upc',
|
|
'ExternalIds.UPCs.DisplayValues.0',
|
|
'externalIds.upcs.0',
|
|
'itemInfo.externalIds.upcs.displayValues.0',
|
|
] 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 extractModel(array $item): ?string
|
|
{
|
|
foreach ([
|
|
'model',
|
|
'Model',
|
|
'ItemInfo.ManufactureInfo.Model.DisplayValue',
|
|
'itemInfo.manufactureInfo.model.displayValue',
|
|
'ItemInfo.Classifications.ProductGroup.DisplayValue',
|
|
'itemInfo.classifications.productGroup.displayValue',
|
|
] as $path) {
|
|
$value = trim((string) data_get($item, $path, ''));
|
|
if ($value !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extractManufacturerPartNumber(array $item): ?string
|
|
{
|
|
foreach ([
|
|
'manufacturer_code',
|
|
'mpn',
|
|
'MPN',
|
|
'ItemInfo.ManufactureInfo.ItemPartNumber.DisplayValue',
|
|
'itemInfo.manufactureInfo.itemPartNumber.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
|
|
{
|
|
return $this->extractImageUrls($item)[0] ?? null;
|
|
}
|
|
|
|
/** @return array<int, string> */
|
|
private function extractImageUrls(array $item): array
|
|
{
|
|
$urls = [];
|
|
|
|
foreach ([
|
|
'Images.Primary.Large.URL',
|
|
'Images.Primary.Medium.URL',
|
|
'Images.Primary.Small.URL',
|
|
'Images.Variants.*.Large.URL',
|
|
'Images.Variants.*.Medium.URL',
|
|
'Images.Variants.*.Small.URL',
|
|
'images.primary.large.url',
|
|
'images.primary.medium.url',
|
|
'images.primary.small.url',
|
|
'images.variants.*.large.url',
|
|
'images.variants.*.medium.url',
|
|
'images.variants.*.small.url',
|
|
'images.*.url',
|
|
'image',
|
|
] as $path) {
|
|
$value = data_get($item, $path);
|
|
foreach (Arr::wrap($value) as $candidate) {
|
|
$url = trim((string) $candidate);
|
|
if ($url !== '' && str_starts_with($url, 'http')) {
|
|
$urls[] = $url;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($urls));
|
|
}
|
|
|
|
private function extractPriceAmount(array $item): ?float
|
|
{
|
|
foreach ([
|
|
'Offers.Listings.0.Price.Amount',
|
|
'Offers.Summaries.0.LowestPrice.Amount',
|
|
'offersV2.listings.0.price.money.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',
|
|
'offersV2.listings.0.price.savingBasis.money.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',
|
|
'offersV2.listings.0.price.money.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;
|
|
}
|
|
|
|
/** @return array<int, string> */
|
|
private function extractFeaturePoints(array $item): array
|
|
{
|
|
$features = [];
|
|
|
|
foreach ([
|
|
'ItemInfo.Features.DisplayValues',
|
|
'itemInfo.features.displayValues',
|
|
'features',
|
|
'featureBullets',
|
|
] as $path) {
|
|
$value = data_get($item, $path);
|
|
foreach (Arr::wrap($value) as $candidate) {
|
|
$text = trim((string) $candidate);
|
|
if ($text !== '') {
|
|
$features[] = $text;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($features));
|
|
}
|
|
|
|
private function buildDescriptionFromAmazon(array $item): ?string
|
|
{
|
|
$features = array_slice($this->extractFeaturePoints($item), 0, 8);
|
|
|
|
return $features !== [] ? implode(PHP_EOL, $features) : null;
|
|
}
|
|
|
|
/** @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildAmazonSnapshot(array $item, array $overrides = []): array
|
|
{
|
|
$snapshot = array_filter([
|
|
'asin' => $this->extractAsin($item),
|
|
'ean' => $this->extractEan($item),
|
|
'upc' => $this->extractUpc($item),
|
|
'title' => $this->extractTitle($item),
|
|
'brand' => $this->extractBrand($item),
|
|
'model' => $this->extractModel($item),
|
|
'manufacturer_code' => $this->extractManufacturerPartNumber($item),
|
|
'detail_url' => $this->extractDetailPageUrl($item),
|
|
'image_url' => $this->extractPrimaryImageUrl($item),
|
|
'image_urls' => $this->extractImageUrls($item),
|
|
'price_amount' => $this->extractPriceAmount($item),
|
|
'price_compare_at' => $this->extractCompareAtAmount($item),
|
|
'currency' => $this->extractCurrency($item),
|
|
'availability' => $this->extractAvailability($item),
|
|
'feature_points' => array_slice($this->extractFeaturePoints($item), 0, 8),
|
|
'synced_at' => now()->toIso8601String(),
|
|
], static fn(mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
|
|
|
return array_filter(array_replace($snapshot, $overrides), static fn(mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
|
}
|
|
|
|
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<int, string> $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, ?string $upc = null, ?string $manufacturerCode = null): ?Product
|
|
{
|
|
$codes = array_values(array_filter([
|
|
$this->normalizeCode($asin),
|
|
$this->normalizeCode($ean),
|
|
$this->normalizeCode($upc),
|
|
$this->normalizeCode($manufacturerCode),
|
|
]));
|
|
|
|
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;
|
|
}
|
|
|
|
/** @param array<string, mixed> $options */
|
|
private function enrichItem(array $item, array $options = []): array
|
|
{
|
|
$asin = $this->extractAsin($item);
|
|
if ($asin === null) {
|
|
return $item;
|
|
}
|
|
|
|
$needsEnrichment = $this->extractManufacturerPartNumber($item) === null
|
|
|| $this->extractEan($item) === null
|
|
|| $this->extractPrimaryImageUrl($item) === null
|
|
|| $this->extractBrand($item) === null
|
|
|| $this->extractPriceAmount($item) === null;
|
|
|
|
if (! $needsEnrichment) {
|
|
return $item;
|
|
}
|
|
|
|
try {
|
|
$response = $this->apiService->getItems([$asin], [
|
|
'marketplace' => (string) ($options['marketplace'] ?? $this->apiService->getMarketplace()),
|
|
]);
|
|
} catch (RuntimeException) {
|
|
return $item;
|
|
}
|
|
|
|
$richItem = $this->pickFirstItem($response);
|
|
|
|
return is_array($richItem) ? $richItem : $item;
|
|
}
|
|
|
|
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, int $sortOrder = 0, array $meta = []): 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->sort_order = max(0, $sortOrder);
|
|
$media->meta = array_replace([
|
|
'provider' => 'amazon_creators_api',
|
|
'remote' => true,
|
|
], $meta);
|
|
$media->save();
|
|
|
|
return $created;
|
|
}
|
|
}
|