netgescon-day0/app/Http/Controllers/PublicAccess/AmazonReferralController.php

519 lines
24 KiB
PHP

<?php
namespace App\Http\Controllers\PublicAccess;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOffer;
use App\Services\Catalog\AmazonCreatorsApiService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\View\View;
class AmazonReferralController
{
public function __construct(
private readonly AmazonCreatorsApiService $amazonCreatorsApiService,
) {
}
public function index(Request $request): View
{
$selectedBrand = Str::upper(trim((string) $request->query('brand', 'EPSON')));
$amazonOffers = $this->resolvePublicShowcaseOffers();
$exampleOffers = $amazonOffers
->filter(fn(ProductOffer $offer): bool => $this->matchesSelectedBrand($offer, $selectedBrand))
->take(8)
->map(function (ProductOffer $offer): array {
$payload = is_array($offer->meta['payload'] ?? null) ? $offer->meta['payload'] : [];
$galleryUrls = $this->resolveGalleryUrls($offer, $payload);
$amazonPrice = $offer->price_amount !== null ? (float) $offer->price_amount : $this->normalizeMoney(data_get($payload, 'price_amount'));
$reference = $this->buildReferenceLabel($payload);
return [
'title' => trim((string) ($offer->title ?: $offer->product?->name ?: 'Prodotto Amazon')),
'internal_code' => trim((string) ($offer->product?->internal_code ?? '')),
'brand' => trim((string) ($offer->product?->brand ?? data_get($payload, 'brand', ''))),
'asin' => trim((string) data_get($payload, 'asin', $offer->external_product_id)),
'reference_label' => $reference['label'],
'reference_value' => $reference['value'],
'price_amount' => $amazonPrice,
'price_compare_at' => $offer->price_compare_at !== null ? (float) $offer->price_compare_at : $this->normalizeMoney(data_get($payload, 'price_compare_at')),
'currency' => trim((string) ($offer->currency ?: data_get($payload, 'currency', 'EUR'))),
'availability' => trim((string) ($offer->availability ?: data_get($payload, 'availability', ''))),
'referral_url' => trim((string) ($offer->referral_url ?: data_get($payload, 'referral_url', ''))),
'detail_url' => trim((string) ($offer->external_url ?: data_get($payload, 'detail_url', ''))),
'image_url' => $galleryUrls[0] ?? '',
'image_urls' => $galleryUrls,
'search_query' => trim((string) data_get($payload, 'search_query', '')),
'sync_source' => trim((string) ($offer->meta['sync_source'] ?? 'amazon_creators_api')),
'synced_at' => trim((string) data_get($payload, 'synced_at', optional($offer->updated_at)->toIso8601String())),
'feature_points' => array_values(array_filter(array_map(static fn(mixed $value): string => trim((string) $value), (array) data_get($payload, 'feature_points', [])))),
];
})->values();
$brandCatalog = $this->buildBrandCatalog($amazonOffers, $selectedBrand);
$featuredComparisons = $this->buildFeaturedComparisons($selectedBrand);
$catalogStats = [
'offers_count' => $amazonOffers->count(),
'products_count' => $amazonOffers->pluck('product_id')->filter()->unique()->count(),
'brand_products_count' => count($brandCatalog),
];
return view('public.amazon-referral', [
'associateTag' => $this->associateTag(),
'defaultQuery' => trim((string) $request->query('q', '')),
'defaultAsin' => trim((string) $request->query('asin', '')),
'defaultUrl' => trim((string) $request->query('url', '')),
'selectedBrand' => $selectedBrand,
'exampleOffers' => $exampleOffers->all(),
'brandCatalog' => $brandCatalog,
'catalogStats' => $catalogStats,
'featuredComparisons' => $featuredComparisons,
'capabilityCards' => $this->capabilityCards(),
'telegramSetup' => $this->telegramSetup(),
'evaluationNotes' => $this->evaluationNotes(),
'kbChecklist' => $this->kbChecklist(),
]);
}
/** @return Collection<int, ProductOffer> */
private function resolvePublicShowcaseOffers(): Collection
{
return ProductOffer::query()
->with(['product', 'product.media'])
->where('source_type', 'amazon_creators_api')
->where('is_active', true)
->latest('last_seen_at')
->latest('id')
->limit(24)
->get()
->filter(fn(ProductOffer $offer): bool => $this->isPublicShowcaseOffer($offer))
->unique(fn(ProductOffer $offer): string => (string) ($offer->product_id ?: $offer->external_product_id ?: $offer->id))
->values();
}
private function matchesSelectedBrand(ProductOffer $offer, string $selectedBrand): bool
{
$brand = trim((string) ($offer->product?->brand ?: data_get($offer->meta, 'payload.brand', '')));
return $brand !== '' && Str::contains(Str::upper($brand), $selectedBrand);
}
private function isPublicShowcaseOffer(ProductOffer $offer): bool
{
$product = $offer->product;
if (! $product instanceof Product) {
return false;
}
$catalogMeta = is_array(data_get($product->meta, 'catalog')) ? data_get($product->meta, 'catalog') : [];
if (($catalogMeta['public_showcase'] ?? false) === true) {
return true;
}
return in_array(strtoupper((string) $offer->external_product_id), $this->featuredShowcaseAsins(), true);
}
/** @return array<int, string> */
private function featuredShowcaseAsins(): array
{
return [
'B0CPPKNRLJ',
'B0B9C3ZVHR',
'B087DFLF9S',
];
}
public function go(Request $request): RedirectResponse
{
$asin = $this->normalizeAsin((string) $request->query('asin', ''));
if ($asin !== null) {
return redirect()->away($this->appendAssociateTag('https://www.amazon.it/dp/' . rawurlencode($asin)));
}
$url = trim((string) $request->query('url', ''));
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
return redirect()->away($this->appendAssociateTag($this->normalizeAmazonUrl($url)));
}
$query = trim((string) $request->query('q', ''));
if ($query !== '') {
return redirect()->away($this->appendAssociateTag('https://www.amazon.it/s?k=' . rawurlencode($query)));
}
return redirect()->route('public.amazon.index');
}
private function associateTag(): string
{
$tag = trim((string) config('services.amazon.referral_tag', ''));
if ($tag === '') {
$tag = trim((string) config('catalog.amazon.associate_tag', 'netgescon-21'));
}
return $tag !== '' ? $tag : 'netgescon-21';
}
private function appendAssociateTag(string $url): string
{
$parts = parse_url($url);
if (! is_array($parts)) {
return $url;
}
$query = [];
parse_str((string) ($parts['query'] ?? ''), $query);
$query['tag'] = $this->associateTag();
$base = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? 'www.amazon.it') . ($parts['path'] ?? '');
return $base . '?' . http_build_query($query);
}
private function normalizeAmazonUrl(string $url): string
{
$asin = $this->extractAsinFromUrl($url);
if ($asin !== null) {
return 'https://www.amazon.it/dp/' . rawurlencode($asin);
}
return $url;
}
private function extractAsinFromUrl(string $url): ?string
{
if (preg_match('~/(?:dp|gp/product)/([A-Z0-9]{10})(?:[/?]|$)~i', $url, $matches)) {
return strtoupper($matches[1]);
}
return null;
}
private function normalizeAsin(string $value): ?string
{
$value = strtoupper(trim($value));
$value = preg_replace('/[^A-Z0-9]/', '', $value) ?? '';
return preg_match('/^[A-Z0-9]{10}$/', $value) ? $value : null;
}
/** @return array<int, array<string, string>> */
private function capabilityCards(): array
{
return [
[
'title' => 'Catalogo interno Amazon',
'status' => 'Attivo',
'tone' => 'live',
'body' => 'La ricerca Amazon e il salvataggio candidati funzionano. Ora salviamo anche snapshot strutturati di prezzo, disponibilita, immagini e metadati di sync.',
],
[
'title' => 'Bulk import risultati',
'status' => 'Attivo',
'tone' => 'live',
'body' => 'Dalla UI fornitore possiamo importare in massa i risultati della query corrente per creare un mini catalogo pronto per prove e riuso interno.',
],
[
'title' => 'Canale Telegram offerte',
'status' => 'Pronto da collegare',
'tone' => 'next',
'body' => 'Abbiamo gia la configurazione base Telegram. Serve aggiungere il bot come admin al canale e usare token e chat id del canale per pubblicare le offerte.',
],
[
'title' => 'Carrello interno -> Amazon',
'status' => 'Da validare',
'tone' => 'review',
'body' => 'Possiamo costruire un carrello interno NetGescon, ma il passaggio affidabile di piu prodotti nel carrello Amazon non e garantito dalle API attuali. La prima strada solida resta aprire link referral dei prodotti selezionati.',
],
[
'title' => 'Fattura e provenienza venditore',
'status' => 'Da testare',
'tone' => 'review',
'body' => 'Dobbiamo verificare se Amazon Creators espone davvero venditore, paese e segnali utili per fattura e garanzia. Se i dati non arrivano via API serve una validazione supplementare.',
],
[
'title' => 'Mini sito brand selezionati',
'status' => 'Attivo',
'tone' => 'live',
'body' => 'La pagina pubblica puo gia funzionare come mini sito vetrina per brand scelti, con immagini, codici prodotto ufficiali e uscita finale verso Amazon tramite referral.',
],
];
}
/** @return array<int, array<string, string>> */
private function telegramSetup(): array
{
return [
['label' => 'Bot Telegram', 'value' => 'Crea o usa un bot con BotFather e recupera il token API.'],
['label' => 'Canale', 'value' => 'Aggiungi il bot come amministratore del canale @nethomestorelettronica con permesso di pubblicare messaggi.'],
['label' => 'Chat ID', 'value' => 'Recupera il chat_id del canale, di solito un id numerico negativo o lo username del canale se accettato dal bot.'],
['label' => 'Config', 'value' => 'Valorizza TELEGRAM_OFFERS_BOT_TOKEN, TELEGRAM_OFFERS_CHAT_ID e TELEGRAM_OFFERS_CHANNEL_NAME in ambiente.'],
['label' => 'Pubblicazione', 'value' => 'Il primo invio puo partire con il publisher offerte; esiste gia un endpoint test Telegram autenticato per invii singoli.'],
];
}
/** @return array<int, array<string, string>> */
private function evaluationNotes(): array
{
return [
[
'title' => 'Messaggio privato a un iscritto del canale',
'body' => 'Non e possibile scrivere in privato a un iscritto solo perche segue il canale. Un bot Telegram puo scrivere a un utente solo se quell utente ha avviato il bot o ha gia aperto una chat diretta con lui.',
],
[
'title' => 'Ordinare prima venditori italiani, poi UE, poi Cina',
'body' => 'E fattibile solo se il dato del merchant o del paese arriva davvero dall API o da una fonte affidabile. Per ora non lo stiamo ancora persistendo, quindi va trattato come test aperto.',
],
[
'title' => 'Fattura e garanzia',
'body' => 'La semplice presenza del prodotto su Amazon non basta. Serve verificare se il listing espone indicatori affidabili su fattura e venditore, altrimenti il dato va raccolto con un controllo operativo separato.',
],
];
}
/** @return array<int, array<string, mixed>> */
private function buildBrandCatalog(Collection $amazonOffers, string $selectedBrand): array
{
return $amazonOffers
->filter(fn(ProductOffer $offer): bool => $this->matchesSelectedBrand($offer, $selectedBrand))
->take(12)
->map(function (ProductOffer $offer): array {
$payload = is_array($offer->meta['payload'] ?? null) ? $offer->meta['payload'] : [];
$galleryUrls = $this->resolveGalleryUrls($offer, $payload);
$amazonPrice = $offer->price_amount !== null ? (float) $offer->price_amount : $this->normalizeMoney(data_get($payload, 'price_amount'));
$reference = $this->buildReferenceLabel($payload);
return [
'title' => trim((string) ($offer->title ?: $offer->product?->name ?: 'Prodotto Amazon')),
'internal_code' => trim((string) ($offer->product?->internal_code ?? '')),
'brand' => trim((string) ($offer->product?->brand ?: data_get($payload, 'brand', ''))),
'asin' => trim((string) data_get($payload, 'asin', $offer->external_product_id)),
'reference' => $reference,
'amazon_price' => $amazonPrice,
'currency' => trim((string) ($offer->currency ?: data_get($payload, 'currency', 'EUR'))),
'availability' => trim((string) ($offer->availability ?: data_get($payload, 'availability', ''))),
'referral_url' => trim((string) ($offer->referral_url ?: data_get($payload, 'referral_url', ''))),
'image_url' => $galleryUrls[0] ?? '',
'image_urls' => $galleryUrls,
'feature_points' => array_values(array_filter(array_map(static fn(mixed $value): string => trim((string) $value), (array) data_get($payload, 'feature_points', [])))),
];
})
->values()
->all();
}
/** @return array<int, array<string, mixed>> */
private function buildFeaturedComparisons(string $selectedBrand): array
{
$examples = [
[
'asin' => 'B0CPPKNRLJ',
'brand' => 'EPSON',
'note' => 'Scheda vetrina ufficiale EPSON con codice produttore, immagini e link referral verso Amazon.',
],
[
'asin' => 'B0B9C3ZVHR',
'brand' => 'SAMSUNG',
'note' => 'Prodotto Samsung da negozio riallineato con metadata Amazon completi: titolo, immagini, codice produttore ed EAN.',
],
];
return collect($examples)
->filter(static fn(array $item): bool => Str::upper((string) ($item['brand'] ?? '')) === $selectedBrand)
->map(function (array $item): array {
$amazon = $this->fetchAmazonShowcaseItem((string) ($item['asin'] ?? ''));
$amazonPrice = $amazon['price_amount'] ?? null;
return [
'asin' => (string) ($item['asin'] ?? ''),
'brand' => (string) ($item['brand'] ?? ''),
'title' => (string) ($amazon['title'] ?? 'Prodotto selezionato'),
'reference' => $this->buildReferenceLabel($amazon),
'detail_url' => (string) ($amazon['detail_url'] ?? ''),
'referral_url' => (string) ($amazon['referral_url'] ?? ''),
'image_url' => (string) ($amazon['image_url'] ?? ''),
'image_urls' => (array) ($amazon['image_urls'] ?? []),
'currency' => (string) ($amazon['currency'] ?? 'EUR'),
'amazon_public_price' => $amazonPrice,
'availability' => (string) ($amazon['availability'] ?? ''),
'note' => (string) ($item['note'] ?? ''),
];
})
->values()
->all();
}
/** @return array<string, mixed> */
private function fetchAmazonShowcaseItem(string $asin): array
{
$asin = strtoupper(trim($asin));
if ($asin === '' || ! $this->amazonCreatorsApiService->isConfigured()) {
return [];
}
try {
$payload = $this->amazonCreatorsApiService->getItems([$asin]);
} catch (\Throwable) {
return [];
}
$item = data_get($payload, 'itemsResult.items.0') ?? data_get($payload, 'getItemsResult.items.0') ?? data_get($payload, 'ItemsResult.Items.0');
if (! is_array($item)) {
return [];
}
$detailUrl = trim((string) data_get($item, 'detailPageURL', ''));
$imageUrls = array_values(array_filter(array_map(
static fn(mixed $value): string => trim((string) (is_array($value) ? data_get($value, 'large.url', data_get($value, 'url', '')) : $value)),
array_merge(
array_filter([(string) data_get($item, 'images.primary.large.url', '')]),
(array) data_get($item, 'images.variants', []),
(array) data_get($item, 'image_urls', [])
)
)));
return [
'title' => trim((string) data_get($item, 'itemInfo.title.displayValue', data_get($item, 'title', 'Prodotto Amazon'))),
'detail_url' => $detailUrl,
'referral_url' => $detailUrl !== '' ? $this->appendAssociateTag($detailUrl) : '',
'image_url' => $imageUrls[0] ?? '',
'image_urls' => $imageUrls,
'manufacturer_code' => trim((string) data_get($item, 'itemInfo.manufactureInfo.itemPartNumber.displayValue', '')),
'ean' => trim((string) data_get($item, 'itemInfo.externalIds.eans.displayValues.0', '')),
'upc' => trim((string) data_get($item, 'itemInfo.externalIds.upcs.displayValues.0', '')),
'price_amount' => $this->normalizeMoney(data_get($item, 'offersV2.listings.0.price.money.amount', data_get($item, 'offers.listings.0.price.amount', data_get($item, 'price.amount')))),
'currency' => trim((string) data_get($item, 'offersV2.listings.0.price.money.currency', data_get($item, 'offers.listings.0.price.currency', data_get($item, 'price.currency', 'EUR')))),
'availability' => trim((string) data_get($item, 'offers.listings.0.availability.message', '')),
];
}
/** @param array<string, mixed> $payload
* @return array{label:string,value:string}
*/
private function buildReferenceLabel(array $payload): array
{
$manufacturerCode = trim((string) data_get($payload, 'manufacturer_code', ''));
if ($manufacturerCode !== '') {
return ['label' => 'Codice produttore', 'value' => $manufacturerCode];
}
$ean = trim((string) data_get($payload, 'ean', ''));
if ($ean !== '') {
return ['label' => 'EAN', 'value' => $ean];
}
$upc = trim((string) data_get($payload, 'upc', ''));
if ($upc !== '') {
return ['label' => 'UPC', 'value' => $upc];
}
return ['label' => '', 'value' => ''];
}
private function resolvePreferredSupplierOffer(?Product $product): ?ProductOffer
{
if (! $product instanceof Product) {
return null;
}
return ProductOffer::query()
->where('product_id', (int) $product->id)
->where('source_type', 'internal_supplier')
->whereNotNull('price_amount')
->orderByDesc('last_seen_at')
->orderByDesc('id')
->first();
}
/** @param array<string, mixed> $payload
* @return array<int, string>
*/
private function resolveGalleryUrls(ProductOffer $offer, array $payload): array
{
$urls = array_values(array_filter(array_map(
static fn(mixed $value): string => trim((string) $value),
(array) data_get($payload, 'image_urls', [])
), static fn(string $value): bool => $value !== ''));
if ($urls !== []) {
return array_values(array_unique($urls));
}
$product = $offer->product;
if (! $product instanceof Product) {
return [];
}
$mediaUrls = $product->media
->sortBy('sort_order')
->map(function (ProductMedia $media): ?string {
$sourceUrl = trim((string) data_get($media->meta, 'source_url', ''));
if ($sourceUrl !== '') {
return $sourceUrl;
}
if (($media->disk ?? '') === 'public' && trim((string) ($media->path ?? '')) !== '') {
return asset('storage/' . ltrim((string) $media->path, '/'));
}
return null;
})
->filter(static fn(?string $value): bool => is_string($value) && $value !== '')
->values()
->all();
return array_values(array_unique($mediaUrls));
}
/** @return array<int, array<string, string>> */
private function kbChecklist(): array
{
return [
['title' => 'Import Amazon', 'body' => 'Cercare da catalogo fornitore, salvare il singolo candidato o tutti i risultati, poi verificare che prezzo, gallery e referral siano finiti nel catalogo interno.'],
['title' => 'Scheda pubblica prodotto', 'body' => 'Esporre immagini, prezzo Amazon, codice produttore o EAN/UPC e pulsante referral senza mostrare il prezzo interno del gestionale.'],
['title' => 'Mini sito brand', 'body' => 'Usare public/amazon come vetrina per brand selezionati come EPSON, mostrando prima il catalogo interno e poi il salto finale su Amazon.'],
['title' => 'Telegram', 'body' => 'Configurare token e chat del canale, provare il dry-run e poi pubblicare una offerta reale con il comando offers:publish-telegram.'],
];
}
private function normalizeMoney(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_string($value)) {
$value = str_replace('.', '', trim($value));
$value = str_replace(',', '.', $value);
}
return is_numeric($value) ? round((float) $value, 2) : null;
}
private function calculateDelta(?float $supplierPrice, ?float $amazonPrice): ?float
{
if ($supplierPrice === null || $amazonPrice === null) {
return null;
}
return round($supplierPrice - $amazonPrice, 2);
}
private function calculateDeltaPercent(?float $supplierPrice, ?float $amazonPrice): ?float
{
if ($supplierPrice === null || $amazonPrice === null || $supplierPrice == 0.0) {
return null;
}
return round((($supplierPrice - $amazonPrice) / $supplierPrice) * 100, 2);
}
}