netgescon-day0/app/Services/Catalog/AmazonCreatorsCatalogSyncService.php

411 lines
14 KiB
PHP

<?php
namespace App\Services\Catalog;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductMedia;
use App\Models\ProductOffer;
use Illuminate\Support\Arr;
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);
$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<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 array<string, mixed>|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 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<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 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;
}
}