1369 lines
44 KiB
PHP
1369 lines
44 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Commerce;
|
|
|
|
use App\Commerce\Dto\CommerceReferenceContext;
|
|
use App\Commerce\Dto\CommerceSearchQuery;
|
|
use App\Commerce\Dto\ShopProductResult;
|
|
use App\Config\ShopServiceConfig;
|
|
use App\Shopware\ShopwareCriteriaBuilder;
|
|
use App\Shopware\StoreApiClient;
|
|
use App\Shopware\StoreApiException;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
|
|
|
final class ShopSearchService
|
|
{
|
|
private const FOCUS_NEUTRAL = 'neutral';
|
|
private const FOCUS_DEVICE = 'device';
|
|
private const FOCUS_ACCESSORY = 'accessory';
|
|
|
|
private bool $lastSearchHadSystemFailure = false;
|
|
private ?string $lastSearchFailureReason = null;
|
|
|
|
public function __construct(
|
|
private readonly CommerceQueryParser $queryParser,
|
|
private readonly ShopwareCriteriaBuilder $criteriaBuilder,
|
|
private readonly StoreApiClient $storeApiClient,
|
|
private readonly ShopServiceConfig $shopConfig,
|
|
private readonly LoggerInterface $logger,
|
|
private readonly bool $enabled = true,
|
|
private readonly int $maxResults = 25,
|
|
private readonly string $baseUrl = ''
|
|
) {
|
|
}
|
|
|
|
public function hadLastSearchSystemFailure(): bool
|
|
{
|
|
return $this->lastSearchHadSystemFailure;
|
|
}
|
|
|
|
public function getLastSearchFailureReason(): ?string
|
|
{
|
|
return $this->lastSearchFailureReason;
|
|
}
|
|
|
|
/**
|
|
* @return ShopProductResult[]
|
|
*/
|
|
public function search(
|
|
string $originalPrompt,
|
|
string $commerceIntent,
|
|
string $commerceHistoryContext = '',
|
|
?CommerceReferenceContext $referenceContext = null
|
|
): array {
|
|
$this->resetLastSearchFailure();
|
|
|
|
if (!$this->enabled) {
|
|
$this->logger->info('Shop search skipped because commerce search is disabled', [
|
|
'commerceIntent' => $commerceIntent,
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$primaryQuery = $this->queryParser->parse(
|
|
$originalPrompt,
|
|
$commerceIntent,
|
|
$commerceHistoryContext
|
|
);
|
|
|
|
$focusMode = $this->determineFocusMode(
|
|
originalPrompt: $originalPrompt,
|
|
referenceContext: $referenceContext
|
|
);
|
|
|
|
$this->logger->info('Shop search started', [
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'normalizedPrompt' => $primaryQuery->normalizedPrompt,
|
|
'searchText' => $primaryQuery->searchText,
|
|
'brand' => $primaryQuery->brand,
|
|
'sizes' => $primaryQuery->sizes,
|
|
'priceMin' => $primaryQuery->priceMin,
|
|
'priceMax' => $primaryQuery->priceMax,
|
|
'focusMode' => $focusMode,
|
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
|
'hasReferenceContext' => $referenceContext !== null,
|
|
'referenceProductName' => $referenceContext?->productName,
|
|
'referenceFocusTerms' => $referenceContext?->focusTerms,
|
|
'criteriaLimit' => $this->maxResults,
|
|
]);
|
|
|
|
$referenceProbeResults = $this->probeReferenceContext(
|
|
originalPrompt: $originalPrompt,
|
|
commerceIntent: $commerceIntent,
|
|
referenceContext: $referenceContext
|
|
);
|
|
|
|
$rankedProducts = $this->executeSearch(
|
|
$primaryQuery,
|
|
$commerceIntent,
|
|
$originalPrompt,
|
|
true
|
|
);
|
|
|
|
if ($rankedProducts === [] && $commerceHistoryContext !== '') {
|
|
$fallbackQuery = $this->queryParser->parse(
|
|
$originalPrompt,
|
|
$commerceIntent,
|
|
''
|
|
);
|
|
|
|
$this->logger->info('Shop search retry without commerce history context', [
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'normalizedPrompt' => $fallbackQuery->normalizedPrompt,
|
|
'searchText' => $fallbackQuery->searchText,
|
|
'brand' => $fallbackQuery->brand,
|
|
'sizes' => $fallbackQuery->sizes,
|
|
'priceMin' => $fallbackQuery->priceMin,
|
|
'priceMax' => $fallbackQuery->priceMax,
|
|
'focusMode' => $focusMode,
|
|
'hasReferenceContext' => $referenceContext !== null,
|
|
'referenceProductName' => $referenceContext?->productName,
|
|
'referenceFocusTerms' => $referenceContext?->focusTerms,
|
|
]);
|
|
|
|
$rankedProducts = $this->executeSearch(
|
|
$fallbackQuery,
|
|
$commerceIntent,
|
|
$originalPrompt,
|
|
false
|
|
);
|
|
}
|
|
|
|
$finalProducts = $this->mergeRankedProductLists(
|
|
$referenceProbeResults,
|
|
$rankedProducts,
|
|
$primaryQuery
|
|
);
|
|
|
|
$finalProducts = $this->applyFocusGuardrails(
|
|
products: $finalProducts,
|
|
focusMode: $focusMode,
|
|
originalPrompt: $originalPrompt,
|
|
referenceContext: $referenceContext
|
|
);
|
|
|
|
$finalProducts = $this->applyPriceFilters(
|
|
products: $finalProducts,
|
|
query: $primaryQuery
|
|
);
|
|
|
|
$this->logger->info('Shop search finished', [
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'focusMode' => $focusMode,
|
|
'referenceProbeResultsCount' => count($referenceProbeResults),
|
|
'rankedProductsCount' => count($finalProducts),
|
|
'topProducts' => array_map(
|
|
static fn(ShopProductResult $product): array => [
|
|
'name' => $product->name,
|
|
'productNumber' => $product->productNumber,
|
|
'manufacturer' => $product->manufacturer,
|
|
'available' => $product->available,
|
|
'price' => $product->price,
|
|
],
|
|
array_slice($finalProducts, 0, $this->shopConfig->getTopProductLogLimit())
|
|
),
|
|
]);
|
|
|
|
return $finalProducts;
|
|
}
|
|
|
|
/**
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function probeReferenceContext(
|
|
string $originalPrompt,
|
|
string $commerceIntent,
|
|
?CommerceReferenceContext $referenceContext
|
|
): array {
|
|
if ($referenceContext === null) {
|
|
return [];
|
|
}
|
|
|
|
$probeQueries = $this->buildReferenceProbeQueries($referenceContext);
|
|
|
|
if ($probeQueries === []) {
|
|
return [];
|
|
}
|
|
|
|
$allResults = [];
|
|
|
|
foreach ($probeQueries as $referenceSearchText) {
|
|
$probeQuery = new CommerceSearchQuery(
|
|
originalPrompt: $originalPrompt,
|
|
normalizedPrompt: mb_strtolower($referenceSearchText, 'UTF-8'),
|
|
searchText: $referenceSearchText,
|
|
brand: $referenceContext->manufacturer,
|
|
sizes: [],
|
|
properties: [],
|
|
priceMin: null,
|
|
priceMax: null,
|
|
intent: $commerceIntent,
|
|
needsLlmFallback: false,
|
|
);
|
|
|
|
$this->logger->info('Shop search reference probe', [
|
|
'originalPrompt' => $originalPrompt,
|
|
'commerceIntent' => $commerceIntent,
|
|
'referenceSearchText' => $referenceSearchText,
|
|
'referenceProductName' => $referenceContext->productName,
|
|
'referenceProductNumber' => $referenceContext->productNumber,
|
|
'referenceFocusTerms' => $referenceContext->focusTerms,
|
|
]);
|
|
|
|
$results = $this->executeSearch(
|
|
$probeQuery,
|
|
$commerceIntent,
|
|
$originalPrompt,
|
|
false
|
|
);
|
|
|
|
if ($results !== []) {
|
|
$allResults = array_merge($allResults, $results);
|
|
}
|
|
}
|
|
|
|
if ($allResults === []) {
|
|
return [];
|
|
}
|
|
|
|
$baseSearchText = $referenceContext->buildReferenceSearchText();
|
|
|
|
$baseQuery = new CommerceSearchQuery(
|
|
originalPrompt: $originalPrompt,
|
|
normalizedPrompt: mb_strtolower($baseSearchText, 'UTF-8'),
|
|
searchText: $baseSearchText,
|
|
brand: $referenceContext->manufacturer,
|
|
sizes: [],
|
|
properties: [],
|
|
priceMin: null,
|
|
priceMax: null,
|
|
intent: $commerceIntent,
|
|
needsLlmFallback: false,
|
|
);
|
|
|
|
return $this->rerankProducts(
|
|
$this->deduplicateProducts($allResults),
|
|
$baseQuery
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function buildReferenceProbeQueries(CommerceReferenceContext $referenceContext): array
|
|
{
|
|
$queries = [];
|
|
|
|
$baseProduct = trim($referenceContext->productName);
|
|
$baseSearch = trim($referenceContext->buildReferenceSearchText());
|
|
|
|
if ($baseSearch !== '') {
|
|
$queries[] = $baseSearch;
|
|
}
|
|
|
|
if ($baseProduct !== '') {
|
|
$queries[] = $baseProduct;
|
|
}
|
|
|
|
foreach ($referenceContext->focusTerms as $focusTerm) {
|
|
if (!is_string($focusTerm)) {
|
|
continue;
|
|
}
|
|
|
|
$focusTerm = trim($focusTerm);
|
|
|
|
if ($focusTerm === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($baseProduct !== '') {
|
|
$queries[] = trim($baseProduct . ' ' . $focusTerm);
|
|
}
|
|
|
|
foreach ($this->expandFocusTermVariants($focusTerm) as $variant) {
|
|
if ($variant === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($baseProduct !== '') {
|
|
$queries[] = trim($baseProduct . ' ' . $variant);
|
|
}
|
|
|
|
if ($referenceContext->productNumber !== null && $referenceContext->productNumber !== '') {
|
|
$queries[] = trim($baseProduct . ' ' . $referenceContext->productNumber . ' ' . $variant);
|
|
}
|
|
}
|
|
}
|
|
|
|
$queries = array_map(
|
|
fn(string $value): string => $this->normalizeForMatching($value),
|
|
$queries
|
|
);
|
|
|
|
$queries = array_values(array_unique(array_filter(
|
|
$queries,
|
|
static fn(string $value): bool => $value !== ''
|
|
)));
|
|
|
|
return $queries;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function expandFocusTermVariants(string $focusTerm): array
|
|
{
|
|
$normalized = $this->normalizeForMatching($focusTerm);
|
|
$variants = [$normalized];
|
|
$variantMap = $this->shopConfig->getAccessoryFocusVariantMap();
|
|
|
|
if (isset($variantMap[$normalized]) && is_array($variantMap[$normalized])) {
|
|
$variants = array_merge($variants, $variantMap[$normalized]);
|
|
}
|
|
|
|
return array_values(array_unique(array_filter(
|
|
array_map(fn(string $value): string => $this->normalizeForMatching($value), $variants),
|
|
static fn(string $value): bool => $value !== ''
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function executeSearch(
|
|
CommerceSearchQuery $query,
|
|
string $commerceIntent,
|
|
string $originalPrompt,
|
|
bool $usesHistoryContext
|
|
): array {
|
|
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
|
|
|
try {
|
|
$response = $this->storeApiClient->searchProducts($criteria);
|
|
return $this->mapAndLogSearchResponse(
|
|
response: $response,
|
|
query: $query,
|
|
commerceIntent: $commerceIntent,
|
|
originalPrompt: $originalPrompt,
|
|
usesHistoryContext: $usesHistoryContext,
|
|
usedSafeCriteria: false
|
|
);
|
|
} catch (StoreApiException $e) {
|
|
if ($e->isSafeCriteriaRetryRecommended()) {
|
|
$safeResults = $this->retryWithSafeCriteria(
|
|
query: $query,
|
|
commerceIntent: $commerceIntent,
|
|
originalPrompt: $originalPrompt,
|
|
usesHistoryContext: $usesHistoryContext,
|
|
previousException: $e
|
|
);
|
|
|
|
if ($safeResults !== null) {
|
|
return $safeResults;
|
|
}
|
|
}
|
|
|
|
$this->recordFailedSearch($e);
|
|
$this->logShopSearchFailure($query, $commerceIntent, $originalPrompt, $usesHistoryContext, $e);
|
|
|
|
return [];
|
|
} catch (
|
|
ClientExceptionInterface |
|
|
RedirectionExceptionInterface |
|
|
ServerExceptionInterface |
|
|
TransportExceptionInterface |
|
|
\RuntimeException $e
|
|
) {
|
|
$this->recordFailedSearch($e);
|
|
$this->logShopSearchFailure($query, $commerceIntent, $originalPrompt, $usesHistoryContext, $e);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return ShopProductResult[]|null
|
|
*/
|
|
private function retryWithSafeCriteria(
|
|
CommerceSearchQuery $query,
|
|
string $commerceIntent,
|
|
string $originalPrompt,
|
|
bool $usesHistoryContext,
|
|
StoreApiException $previousException
|
|
): ?array {
|
|
$this->logger->warning('Shop search retrying with safe criteria', [
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'normalizedPrompt' => $query->normalizedPrompt,
|
|
'searchText' => $query->searchText,
|
|
'usesHistoryContext' => $usesHistoryContext,
|
|
'previousStatusCode' => $previousException->getStatusCode(),
|
|
'previousUtf8Failure' => $previousException->isUtf8Failure(),
|
|
'previousExceptionMessage' => $previousException->getMessage(),
|
|
]);
|
|
|
|
try {
|
|
$safeCriteria = $this->criteriaBuilder->buildSafe($query, $this->maxResults);
|
|
$response = $this->storeApiClient->searchProducts($safeCriteria);
|
|
|
|
return $this->mapAndLogSearchResponse(
|
|
response: $response,
|
|
query: $query,
|
|
commerceIntent: $commerceIntent,
|
|
originalPrompt: $originalPrompt,
|
|
usesHistoryContext: $usesHistoryContext,
|
|
usedSafeCriteria: true
|
|
);
|
|
} catch (
|
|
ClientExceptionInterface |
|
|
RedirectionExceptionInterface |
|
|
ServerExceptionInterface |
|
|
TransportExceptionInterface |
|
|
\RuntimeException $safeException
|
|
) {
|
|
$this->recordFailedSearch($safeException);
|
|
$this->logger->warning('Shop search safe criteria retry failed', [
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'normalizedPrompt' => $query->normalizedPrompt,
|
|
'searchText' => $query->searchText,
|
|
'usesHistoryContext' => $usesHistoryContext,
|
|
'exceptionClass' => $safeException::class,
|
|
'exceptionMessage' => $safeException->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $response
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function mapAndLogSearchResponse(
|
|
array $response,
|
|
CommerceSearchQuery $query,
|
|
string $commerceIntent,
|
|
string $originalPrompt,
|
|
bool $usesHistoryContext,
|
|
bool $usedSafeCriteria
|
|
): array {
|
|
$mappedProducts = $this->mapProducts($response);
|
|
$rankedProducts = $this->rerankProducts($mappedProducts, $query);
|
|
|
|
$this->logger->info('Shop search request finished', [
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'normalizedPrompt' => $query->normalizedPrompt,
|
|
'searchText' => $query->searchText,
|
|
'brand' => $query->brand,
|
|
'sizes' => $query->sizes,
|
|
'priceMin' => $query->priceMin,
|
|
'priceMax' => $query->priceMax,
|
|
'usesHistoryContext' => $usesHistoryContext,
|
|
'usedSafeCriteria' => $usedSafeCriteria,
|
|
'rawElementsCount' => is_array($response['elements'] ?? null) ? count($response['elements']) : 0,
|
|
'mappedProductsCount' => count($mappedProducts),
|
|
'rankedProductsCount' => count($rankedProducts),
|
|
]);
|
|
|
|
return $rankedProducts;
|
|
}
|
|
|
|
private function logShopSearchFailure(
|
|
CommerceSearchQuery $query,
|
|
string $commerceIntent,
|
|
string $originalPrompt,
|
|
bool $usesHistoryContext,
|
|
\Throwable $e
|
|
): void {
|
|
$this->logger->warning('Shop search request failed', [
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'normalizedPrompt' => $query->normalizedPrompt,
|
|
'searchText' => $query->searchText,
|
|
'brand' => $query->brand,
|
|
'sizes' => $query->sizes,
|
|
'priceMin' => $query->priceMin,
|
|
'priceMax' => $query->priceMax,
|
|
'usesHistoryContext' => $usesHistoryContext,
|
|
'systemFailure' => $this->lastSearchHadSystemFailure,
|
|
'failureReason' => $this->lastSearchFailureReason,
|
|
'exceptionClass' => $e::class,
|
|
'exceptionMessage' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
private function resetLastSearchFailure(): void
|
|
{
|
|
$this->lastSearchHadSystemFailure = false;
|
|
$this->lastSearchFailureReason = null;
|
|
}
|
|
|
|
private function recordFailedSearch(\Throwable $e): void
|
|
{
|
|
$isSystemFailure = $e instanceof StoreApiException
|
|
? $e->isSystemFailure()
|
|
: $e instanceof ServerExceptionInterface || $e instanceof TransportExceptionInterface;
|
|
|
|
if (!$isSystemFailure) {
|
|
return;
|
|
}
|
|
|
|
$this->lastSearchHadSystemFailure = true;
|
|
$this->lastSearchFailureReason = $e->getMessage();
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $referenceProbeResults
|
|
* @param ShopProductResult[] $rankedProducts
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function mergeRankedProductLists(
|
|
array $referenceProbeResults,
|
|
array $rankedProducts,
|
|
CommerceSearchQuery $primaryQuery
|
|
): array {
|
|
if ($referenceProbeResults === [] && $rankedProducts === []) {
|
|
return [];
|
|
}
|
|
|
|
$merged = $this->deduplicateProducts(array_merge($referenceProbeResults, $rankedProducts));
|
|
|
|
return $this->rerankProducts($merged, $primaryQuery);
|
|
}
|
|
|
|
private function determineFocusMode(
|
|
string $originalPrompt,
|
|
?CommerceReferenceContext $referenceContext
|
|
): string {
|
|
$normalizedPrompt = $this->normalizeForMatching($originalPrompt);
|
|
|
|
if ($this->containsAnyKeyword($normalizedPrompt, $this->shopConfig->getDeviceFocusKeywords())) {
|
|
return self::FOCUS_DEVICE;
|
|
}
|
|
|
|
if ($this->containsAnyKeyword($normalizedPrompt, $this->shopConfig->getAccessoryFocusKeywords())) {
|
|
return self::FOCUS_ACCESSORY;
|
|
}
|
|
|
|
foreach ($referenceContext?->focusTerms ?? [] as $focusTerm) {
|
|
if (!is_string($focusTerm)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedFocusTerm = $this->normalizeForMatching($focusTerm);
|
|
|
|
if ($normalizedFocusTerm === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($this->isAccessoryFocusToken($normalizedFocusTerm)) {
|
|
return self::FOCUS_ACCESSORY;
|
|
}
|
|
}
|
|
|
|
return self::FOCUS_NEUTRAL;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $products
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function applyFocusGuardrails(
|
|
array $products,
|
|
string $focusMode,
|
|
string $originalPrompt,
|
|
?CommerceReferenceContext $referenceContext
|
|
): array {
|
|
if ($products === []) {
|
|
return [];
|
|
}
|
|
|
|
return match ($focusMode) {
|
|
self::FOCUS_ACCESSORY => $this->filterForAccessoryFocus(
|
|
products: $products,
|
|
originalPrompt: $originalPrompt,
|
|
referenceContext: $referenceContext
|
|
),
|
|
self::FOCUS_DEVICE => $this->filterForDeviceFocus(
|
|
products: $products,
|
|
originalPrompt: $originalPrompt
|
|
),
|
|
default => $products,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $products
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function filterForAccessoryFocus(
|
|
array $products,
|
|
string $originalPrompt,
|
|
?CommerceReferenceContext $referenceContext
|
|
): array {
|
|
$normalizedPrompt = $this->normalizeForMatching($originalPrompt);
|
|
$focusTerms = $this->extractAccessoryFocusTerms($normalizedPrompt, $referenceContext);
|
|
|
|
if ($focusTerms === []) {
|
|
return $products;
|
|
}
|
|
|
|
$accessoryMatches = [];
|
|
$deviceMatches = [];
|
|
$neutralMatches = [];
|
|
|
|
foreach ($products as $product) {
|
|
$isAccessoryLike = $this->isAccessoryLikeProduct($product);
|
|
$isDeviceLike = $this->isDeviceLikeProduct($product);
|
|
$matchesFocus = $this->productMatchesAnyFocusTerm($product, $focusTerms);
|
|
|
|
if ($matchesFocus && $isAccessoryLike) {
|
|
$accessoryMatches[] = $product;
|
|
continue;
|
|
}
|
|
|
|
if ($matchesFocus) {
|
|
$neutralMatches[] = $product;
|
|
continue;
|
|
}
|
|
|
|
if ($isDeviceLike && !$isAccessoryLike) {
|
|
$deviceMatches[] = $product;
|
|
continue;
|
|
}
|
|
|
|
$neutralMatches[] = $product;
|
|
}
|
|
|
|
if ($accessoryMatches !== []) {
|
|
$filtered = array_merge($accessoryMatches, $neutralMatches);
|
|
|
|
$this->logger->info('Accessory focus guardrail kept focused accessory-like results', [
|
|
'originalPrompt' => $originalPrompt,
|
|
'focusTerms' => $focusTerms,
|
|
'beforeCount' => count($products),
|
|
'afterCount' => count($filtered),
|
|
]);
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
if ($deviceMatches !== [] && $neutralMatches === []) {
|
|
$this->logger->info('Accessory focus guardrail suppressed device-only results', [
|
|
'originalPrompt' => $originalPrompt,
|
|
'focusTerms' => $focusTerms,
|
|
'suppressedDeviceCount' => count($deviceMatches),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
return $neutralMatches !== [] ? $neutralMatches : $products;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $products
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function filterForDeviceFocus(array $products, string $originalPrompt): array
|
|
{
|
|
$deviceMatches = [];
|
|
$neutralMatches = [];
|
|
$accessoryMatches = [];
|
|
|
|
foreach ($products as $product) {
|
|
$isAccessoryLike = $this->isAccessoryLikeProduct($product);
|
|
$isDeviceLike = $this->isDeviceLikeProduct($product);
|
|
|
|
if ($isDeviceLike && !$isAccessoryLike) {
|
|
$deviceMatches[] = $product;
|
|
continue;
|
|
}
|
|
|
|
if ($isAccessoryLike && !$isDeviceLike) {
|
|
$accessoryMatches[] = $product;
|
|
continue;
|
|
}
|
|
|
|
$neutralMatches[] = $product;
|
|
}
|
|
|
|
if ($deviceMatches !== []) {
|
|
$filtered = array_merge($deviceMatches, $neutralMatches);
|
|
|
|
$this->logger->info('Device focus guardrail kept device-like results', [
|
|
'originalPrompt' => $originalPrompt,
|
|
'beforeCount' => count($products),
|
|
'afterCount' => count($filtered),
|
|
]);
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
if ($accessoryMatches !== [] && $neutralMatches === []) {
|
|
$this->logger->info('Device focus guardrail suppressed accessory-only results', [
|
|
'originalPrompt' => $originalPrompt,
|
|
'suppressedAccessoryCount' => count($accessoryMatches),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
return $neutralMatches !== [] ? $neutralMatches : $products;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $products
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function applyPriceFilters(array $products, CommerceSearchQuery $query): array
|
|
{
|
|
if ($products === []) {
|
|
return [];
|
|
}
|
|
|
|
if ($query->priceMin === null && $query->priceMax === null) {
|
|
return $products;
|
|
}
|
|
|
|
$filtered = [];
|
|
|
|
foreach ($products as $product) {
|
|
$price = $this->extractNumericPrice($product->price);
|
|
|
|
if ($price === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($query->priceMin !== null && $price < $query->priceMin) {
|
|
continue;
|
|
}
|
|
|
|
if ($query->priceMax !== null && $price > $query->priceMax) {
|
|
continue;
|
|
}
|
|
|
|
$filtered[] = $product;
|
|
}
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractAccessoryFocusTerms(string $normalizedPrompt, ?CommerceReferenceContext $referenceContext): array
|
|
{
|
|
$terms = [];
|
|
|
|
foreach ($referenceContext?->focusTerms ?? [] as $focusTerm) {
|
|
if (!is_string($focusTerm)) {
|
|
continue;
|
|
}
|
|
|
|
$normalized = $this->normalizeForMatching($focusTerm);
|
|
|
|
if ($normalized !== '' && $this->isAccessoryFocusToken($normalized)) {
|
|
$terms[$normalized] = $normalized;
|
|
}
|
|
}
|
|
|
|
foreach ($this->shopConfig->getAccessoryFocusKeywords() as $candidate) {
|
|
$normalizedCandidate = $this->normalizeForMatching($candidate);
|
|
|
|
if ($normalizedCandidate !== '' && str_contains($normalizedPrompt, $normalizedCandidate)) {
|
|
$terms[$normalizedCandidate] = $normalizedCandidate;
|
|
}
|
|
}
|
|
|
|
return array_values($terms);
|
|
}
|
|
|
|
private function isAccessoryFocusToken(string $token): bool
|
|
{
|
|
foreach ($this->shopConfig->getAccessoryFocusKeywords() as $candidate) {
|
|
if ($token === $this->normalizeForMatching($candidate)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $focusTerms
|
|
*/
|
|
private function productMatchesAnyFocusTerm(ShopProductResult $product, array $focusTerms): bool
|
|
{
|
|
if ($focusTerms === []) {
|
|
return false;
|
|
}
|
|
|
|
$corpus = $this->buildNormalizedProductCorpus($product);
|
|
|
|
foreach ($focusTerms as $focusTerm) {
|
|
if ($focusTerm === '') {
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($corpus, $focusTerm)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $keywords
|
|
*/
|
|
private function containsAnyKeyword(string $text, array $keywords): bool
|
|
{
|
|
foreach ($keywords as $keyword) {
|
|
$normalizedKeyword = $this->normalizeForMatching($keyword);
|
|
|
|
if ($normalizedKeyword !== '' && str_contains($text, $normalizedKeyword)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function extractNumericPrice(?string $price): ?float
|
|
{
|
|
if ($price === null) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = str_replace(
|
|
$this->shopConfig->getPriceNormalizationSearch(),
|
|
$this->shopConfig->getPriceNormalizationReplace(),
|
|
$price
|
|
);
|
|
|
|
return is_numeric($normalized) ? (float) $normalized : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $response
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function mapProducts(array $response): array
|
|
{
|
|
$elements = $response['elements'] ?? [];
|
|
|
|
if (!is_array($elements)) {
|
|
return [];
|
|
}
|
|
|
|
$results = [];
|
|
|
|
foreach ($elements as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$relativeUrl = $this->extractUrl($row);
|
|
|
|
$results[] = new ShopProductResult(
|
|
id: (string) ($row['id'] ?? ''),
|
|
name: trim((string) ($row['translated']['name'] ?? '')),
|
|
productNumber: isset($row['productNumber']) ? (string) $row['productNumber'] : null,
|
|
manufacturer: $this->extractManufacturer($row),
|
|
price: $this->extractPrice($row),
|
|
available: isset($row['available']) ? (bool) $row['available'] : null,
|
|
url: $this->buildAbsoluteUrl($relativeUrl),
|
|
highlights: $this->extractHighlights($row),
|
|
description: $this->cleanUpDescription($row),
|
|
productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? $this->shopConfig->getMissingProductImagePlaceholder(),
|
|
customFields: $this->getRelevantCustomFields($row['customFields'] ?? [])
|
|
);
|
|
}
|
|
|
|
$results = array_values(array_filter(
|
|
$results,
|
|
static fn(ShopProductResult $product): bool => $product->name !== ''
|
|
));
|
|
|
|
return $this->deduplicateProducts($results);
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $products
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function rerankProducts(array $products, CommerceSearchQuery $query): array
|
|
{
|
|
if (count($products) <= 1) {
|
|
return $products;
|
|
}
|
|
|
|
$decorated = [];
|
|
|
|
foreach ($products as $index => $product) {
|
|
$decorated[] = [
|
|
'index' => $index,
|
|
'score' => $this->scoreProduct($product, $query),
|
|
'product' => $product,
|
|
];
|
|
}
|
|
|
|
usort($decorated, static function (array $a, array $b): int {
|
|
if ($a['score'] === $b['score']) {
|
|
return $a['index'] <=> $b['index'];
|
|
}
|
|
|
|
return $b['score'] <=> $a['score'];
|
|
});
|
|
|
|
return array_values(array_map(
|
|
static fn(array $entry): ShopProductResult => $entry['product'],
|
|
$decorated
|
|
));
|
|
}
|
|
|
|
private function scoreProduct(ShopProductResult $product, CommerceSearchQuery $query): int
|
|
{
|
|
$score = 0;
|
|
$normalizedPrompt = $this->normalizeForMatching(
|
|
$query->normalizedPrompt !== '' ? $query->normalizedPrompt : $query->originalPrompt
|
|
);
|
|
$normalizedSearchText = $this->normalizeForMatching($query->searchText);
|
|
$normalizedBrand = $this->normalizeForMatching((string) ($query->brand ?? ''));
|
|
$normalizedSizes = array_values(array_filter(array_map(
|
|
fn(mixed $size): string => $this->normalizeForMatching((string) $size),
|
|
$query->sizes
|
|
)));
|
|
|
|
$normalizedQuery = trim(implode(' ', array_filter([
|
|
$normalizedPrompt,
|
|
$normalizedSearchText,
|
|
$normalizedBrand,
|
|
implode(' ', $normalizedSizes),
|
|
])));
|
|
|
|
$queryTokens = $this->tokenize($normalizedQuery);
|
|
$queryNumberTokens = $this->extractNumberTokens($queryTokens);
|
|
|
|
$normalizedProductName = $this->normalizeForMatching($product->name);
|
|
$normalizedProductNumber = $this->normalizeForMatching((string) ($product->productNumber ?? ''));
|
|
$normalizedManufacturer = $this->normalizeForMatching((string) ($product->manufacturer ?? ''));
|
|
$normalizedProductCorpus = $this->buildNormalizedProductCorpus($product);
|
|
|
|
$productNameTokens = $this->tokenize($normalizedProductName);
|
|
$productNumberTokens = $this->tokenize($normalizedProductNumber);
|
|
$productCorpusTokens = $this->tokenize($normalizedProductCorpus);
|
|
|
|
$productNameNumberTokens = $this->extractNumberTokens($productNameTokens);
|
|
$productNumberNumberTokens = $this->extractNumberTokens($productNumberTokens);
|
|
$productCorpusNumberTokens = $this->extractNumberTokens($productCorpusTokens);
|
|
|
|
if ($normalizedProductNumber !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductNumber)) {
|
|
$score += $this->shopConfig->getExactProductNumberPhraseScore();
|
|
}
|
|
|
|
if ($normalizedProductName !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductName)) {
|
|
$score += $this->shopConfig->getExactProductNamePhraseScore();
|
|
}
|
|
|
|
if ($normalizedBrand !== '') {
|
|
if ($normalizedManufacturer !== '' && $normalizedManufacturer === $normalizedBrand) {
|
|
$score += $this->shopConfig->getExactManufacturerMatchScore();
|
|
} elseif ($this->containsWholePhrase($normalizedProductName, $normalizedBrand)) {
|
|
$score += $this->shopConfig->getBrandContainedInNameScore();
|
|
}
|
|
}
|
|
|
|
$score += $this->countOverlap($queryTokens, $productNameTokens) * $this->shopConfig->getNameTokenOverlapWeight();
|
|
$score += $this->countOverlap($queryTokens, $productNumberTokens) * $this->shopConfig->getProductNumberTokenOverlapWeight();
|
|
$score += $this->countOverlap($queryTokens, $productCorpusTokens) * $this->shopConfig->getCorpusTokenOverlapWeight();
|
|
|
|
$score += $this->countOverlap($queryNumberTokens, $productNameNumberTokens) * $this->shopConfig->getNameNumberOverlapWeight();
|
|
$score += $this->countOverlap($queryNumberTokens, $productNumberNumberTokens) * $this->shopConfig->getProductNumberNumberOverlapWeight();
|
|
$score += $this->countOverlap($queryNumberTokens, $productCorpusNumberTokens) * $this->shopConfig->getCorpusNumberOverlapWeight();
|
|
|
|
foreach ($normalizedSizes as $normalizedSize) {
|
|
if ($normalizedSize === '') {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
$this->containsWholePhrase($normalizedProductName, $normalizedSize)
|
|
|| $this->containsWholePhrase($normalizedProductNumber, $normalizedSize)
|
|
|| $this->containsWholePhrase($normalizedProductCorpus, $normalizedSize)
|
|
) {
|
|
$score += $this->shopConfig->getSizeMatchScore();
|
|
}
|
|
}
|
|
|
|
$score += $this->scoreProductTypeMatch($product, $normalizedQuery);
|
|
|
|
if ($product->available === true) {
|
|
$score += $this->shopConfig->getAvailabilityBonusScore();
|
|
}
|
|
|
|
return $score;
|
|
}
|
|
|
|
private function scoreProductTypeMatch(ShopProductResult $product, string $normalizedQuery): int
|
|
{
|
|
$score = 0;
|
|
|
|
$isDeviceQuery = $this->isDeviceQuery($normalizedQuery);
|
|
$isAccessoryQuery = $this->isAccessoryQuery($normalizedQuery);
|
|
|
|
if (!$isDeviceQuery && !$isAccessoryQuery) {
|
|
return 0;
|
|
}
|
|
|
|
$isAccessoryLikeProduct = $this->isAccessoryLikeProduct($product);
|
|
$isDeviceLikeProduct = $this->isDeviceLikeProduct($product);
|
|
|
|
if ($isDeviceQuery && !$isAccessoryQuery) {
|
|
if ($isDeviceLikeProduct) {
|
|
$score += $this->shopConfig->getDeviceQueryDeviceProductBonus();
|
|
}
|
|
|
|
if ($isAccessoryLikeProduct) {
|
|
$score -= $this->shopConfig->getDeviceQueryAccessoryPenalty();
|
|
}
|
|
}
|
|
|
|
if ($isAccessoryQuery) {
|
|
if ($isAccessoryLikeProduct) {
|
|
$score += $this->shopConfig->getAccessoryQueryAccessoryProductBonus();
|
|
}
|
|
|
|
if ($isDeviceLikeProduct) {
|
|
$score += $this->shopConfig->getAccessoryQueryDeviceProductBonus();
|
|
}
|
|
}
|
|
|
|
return $score;
|
|
}
|
|
|
|
private function isDeviceQuery(string $normalizedQuery): bool
|
|
{
|
|
foreach ($this->shopConfig->getDeviceQueryKeywords() as $keyword) {
|
|
if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function isAccessoryQuery(string $normalizedQuery): bool
|
|
{
|
|
foreach ($this->shopConfig->getAccessoryQueryKeywords() as $keyword) {
|
|
if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function isAccessoryLikeProduct(ShopProductResult $product): bool
|
|
{
|
|
$corpus = $this->buildNormalizedProductCorpus($product);
|
|
|
|
foreach ($this->shopConfig->getAccessoryProductKeywords() as $keyword) {
|
|
if (str_contains($corpus, $this->normalizeForMatching($keyword))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function isDeviceLikeProduct(ShopProductResult $product): bool
|
|
{
|
|
$corpus = $this->buildNormalizedProductCorpus($product);
|
|
|
|
foreach ($this->shopConfig->getDeviceProductKeywords() as $keyword) {
|
|
if (str_contains($corpus, $this->normalizeForMatching($keyword))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function buildNormalizedProductCorpus(ShopProductResult $product): string
|
|
{
|
|
return $this->normalizeForMatching(implode(' ', array_filter([
|
|
$product->name,
|
|
$product->productNumber,
|
|
$product->manufacturer,
|
|
implode(' ', $product->highlights),
|
|
$product->description,
|
|
$product->customFields,
|
|
$product->url,
|
|
])));
|
|
}
|
|
|
|
/**
|
|
* @param string[] $left
|
|
* @param string[] $right
|
|
*/
|
|
private function countOverlap(array $left, array $right): int
|
|
{
|
|
if ($left === [] || $right === []) {
|
|
return 0;
|
|
}
|
|
|
|
$leftSet = array_fill_keys($left, true);
|
|
$rightSet = array_fill_keys($right, true);
|
|
|
|
return count(array_intersect_key($leftSet, $rightSet));
|
|
}
|
|
|
|
/**
|
|
* @param string[] $tokens
|
|
* @return string[]
|
|
*/
|
|
private function extractNumberTokens(array $tokens): array
|
|
{
|
|
return array_values(array_filter(
|
|
$tokens,
|
|
fn(string $token): bool => preg_match($this->shopConfig->getContainsDigitPattern(), $token) === 1
|
|
));
|
|
}
|
|
|
|
private function normalizeForMatching(string $value): string
|
|
{
|
|
$value = mb_strtolower(trim($value), 'UTF-8');
|
|
$value = preg_replace($this->shopConfig->getMatchingCleanupPattern(), ' ', $value) ?? $value;
|
|
$value = preg_replace($this->shopConfig->getWhitespaceCollapsePattern(), ' ', $value) ?? $value;
|
|
|
|
return trim($value);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function tokenize(string $value): array
|
|
{
|
|
if ($value === '') {
|
|
return [];
|
|
}
|
|
|
|
return preg_split(
|
|
$this->shopConfig->getTokenSplitPattern(),
|
|
$value,
|
|
-1,
|
|
PREG_SPLIT_NO_EMPTY
|
|
) ?: [];
|
|
}
|
|
|
|
private function containsWholePhrase(string $normalizedText, string $normalizedPhrase): bool
|
|
{
|
|
if ($normalizedText === '' || $normalizedPhrase === '') {
|
|
return false;
|
|
}
|
|
|
|
return str_contains(
|
|
$this->shopConfig->wrapWithPaddingSpaces($normalizedText),
|
|
$this->shopConfig->wrapWithPaddingSpaces($normalizedPhrase)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $customField
|
|
*/
|
|
private function getRelevantCustomFields(array $customField): string
|
|
{
|
|
$primary = (string) ($customField[$this->shopConfig->getPrimaryCustomFieldKey()] ?? '');
|
|
$secondary = (string) ($customField[$this->shopConfig->getSecondaryCustomFieldKey()] ?? '');
|
|
$useCases = (string) ($customField[$this->shopConfig->getUseCasesCustomFieldKey()] ?? '');
|
|
$languages = (string) ($customField[$this->shopConfig->getLanguagesCustomFieldKey()] ?? '');
|
|
|
|
$parts = [];
|
|
|
|
if ($primary !== '' || $secondary !== '') {
|
|
$parts[] = trim($primary . $this->shopConfig->getPrimarySecondarySeparator() . $secondary);
|
|
}
|
|
|
|
if ($useCases !== '') {
|
|
$parts[] = $this->shopConfig->getUseCasesLabel() . $useCases;
|
|
}
|
|
|
|
if ($languages !== '') {
|
|
$parts[] = $this->shopConfig->getLanguagesLabel() . $languages;
|
|
}
|
|
|
|
return trim(implode($this->shopConfig->getCustomFieldJoinSeparator(), array_filter($parts)));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $description
|
|
*/
|
|
private function cleanUpDescription(array $description): string
|
|
{
|
|
if (!isset($description['translated']['description'])) {
|
|
return '';
|
|
}
|
|
|
|
$newDesc = strip_tags((string) ($description['translated']['description']));
|
|
$newDesc = html_entity_decode($newDesc);
|
|
$newDesc = preg_replace($this->shopConfig->getDescriptionEmptyLinePattern(), '', $newDesc) ?? $newDesc;
|
|
$newDesc = preg_replace($this->shopConfig->getDescriptionWhitespaceCleanupPattern(), ' ', $newDesc) ?? $newDesc;
|
|
$result = trim((string) $newDesc);
|
|
|
|
return mb_substr($result, 0, $this->shopConfig->getDescriptionMaxLength());
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function extractManufacturer(array $row): ?string
|
|
{
|
|
$manufacturer = $row['manufacturer'] ?? null;
|
|
|
|
if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) {
|
|
$name = trim($manufacturer['name']);
|
|
|
|
return $name !== '' ? $name : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function extractPrice(array $row): ?string
|
|
{
|
|
$calculatedPrice = $row['calculatedPrice'] ?? null;
|
|
|
|
if (!is_array($calculatedPrice)) {
|
|
return null;
|
|
}
|
|
|
|
$candidates = [
|
|
$calculatedPrice['unitPrice'] ?? null,
|
|
$calculatedPrice['totalPrice'] ?? null,
|
|
$calculatedPrice['referencePrice'] ?? null,
|
|
$calculatedPrice['listPrice'] ?? null,
|
|
$calculatedPrice['regulationPrice'] ?? null,
|
|
];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (!is_numeric($candidate)) {
|
|
continue;
|
|
}
|
|
|
|
$value = (float) $candidate;
|
|
|
|
if ($value > 0.0) {
|
|
return number_format(
|
|
$value,
|
|
$this->shopConfig->getPriceDecimals(),
|
|
$this->shopConfig->getPriceDecimalSeparator(),
|
|
$this->shopConfig->getPriceThousandsSeparator()
|
|
) . $this->shopConfig->getPriceSuffix();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function extractUrl(array $row): ?string
|
|
{
|
|
$seoUrls = $row['seoUrls'] ?? null;
|
|
|
|
if (!is_array($seoUrls) || $seoUrls === []) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($seoUrls as $seoUrl) {
|
|
if (!is_array($seoUrl)) {
|
|
continue;
|
|
}
|
|
|
|
$path = $seoUrl['seoPathInfo'] ?? null;
|
|
|
|
if (is_string($path) && trim($path) !== '') {
|
|
return $this->shopConfig->buildRelativeSeoUrl($path);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function buildAbsoluteUrl(?string $relativeUrl): ?string
|
|
{
|
|
if ($relativeUrl === null || trim($relativeUrl) === '') {
|
|
return null;
|
|
}
|
|
|
|
return rtrim($this->baseUrl, '/') . '/' . ltrim($relativeUrl, '/');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return string[]
|
|
*/
|
|
private function extractHighlights(array $row): array
|
|
{
|
|
$highlights = [];
|
|
|
|
if (isset($row['available'])) {
|
|
$highlights[] = (bool) $row['available']
|
|
? $this->shopConfig->getAvailableHighlightLabel()
|
|
: $this->shopConfig->getUnavailableHighlightLabel();
|
|
}
|
|
|
|
if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') {
|
|
$highlights[] = $this->shopConfig->getProductNumberHighlightPrefix() . trim($row['productNumber']);
|
|
}
|
|
|
|
return array_values(array_unique($highlights));
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $products
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function deduplicateProducts(array $products): array
|
|
{
|
|
$unique = [];
|
|
$seen = [];
|
|
|
|
foreach ($products as $product) {
|
|
$key = mb_strtolower(trim(implode($this->shopConfig->getDeduplicationSeparator(), [
|
|
$product->id,
|
|
$product->productNumber ?? '',
|
|
$product->name,
|
|
$product->url ?? '',
|
|
])), 'UTF-8');
|
|
|
|
if (isset($seen[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$key] = true;
|
|
$unique[] = $product;
|
|
}
|
|
|
|
return $unique;
|
|
}
|
|
} |