add shopware store-api
This commit is contained in:
267
src/Commerce/CommerceQueryParser.php
Normal file
267
src/Commerce/CommerceQueryParser.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce;
|
||||
|
||||
use App\Commerce\Dto\CommerceSearchQuery;
|
||||
|
||||
final class CommerceQueryParser
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private array $knownColors = [
|
||||
'schwarz',
|
||||
'weiß',
|
||||
'weiss',
|
||||
'rot',
|
||||
'blau',
|
||||
'grün',
|
||||
'gruen',
|
||||
'gelb',
|
||||
'grau',
|
||||
'beige',
|
||||
'rosa',
|
||||
'pink',
|
||||
'orange',
|
||||
'braun',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private array $knownCategories = [
|
||||
'sneaker',
|
||||
'schuhe',
|
||||
'hoodie',
|
||||
't-shirt',
|
||||
'shirt',
|
||||
'jacke',
|
||||
'regenjacke',
|
||||
'trinkflasche',
|
||||
'flasche',
|
||||
'rucksack',
|
||||
'tasche',
|
||||
'mütze',
|
||||
'muetze',
|
||||
'kappe',
|
||||
'hose',
|
||||
'pullover',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private array $knownBrands = [
|
||||
'nike',
|
||||
'adidas',
|
||||
'puma',
|
||||
'reebok',
|
||||
'under armour',
|
||||
'new balance',
|
||||
];
|
||||
|
||||
public function parse(string $originalPrompt, string $intent): CommerceSearchQuery
|
||||
{
|
||||
$normalized = $this->normalize($originalPrompt);
|
||||
|
||||
[$priceMin, $priceMax] = $this->extractPriceRange($normalized);
|
||||
$sizes = $this->extractSizes($normalized);
|
||||
$colors = $this->extractColors($normalized);
|
||||
$brand = $this->extractBrand($normalized);
|
||||
$category = $this->extractCategory($normalized);
|
||||
$properties = [];
|
||||
|
||||
$searchText = $this->buildSearchText(
|
||||
$normalized,
|
||||
$colors,
|
||||
$sizes,
|
||||
$brand,
|
||||
$priceMin,
|
||||
$priceMax
|
||||
);
|
||||
|
||||
return new CommerceSearchQuery(
|
||||
originalPrompt: $originalPrompt,
|
||||
normalizedPrompt: $normalized,
|
||||
searchText: $searchText !== '' ? $searchText : $normalized,
|
||||
category: $category,
|
||||
brand: $brand,
|
||||
colors: $colors,
|
||||
sizes: $sizes,
|
||||
properties: $properties,
|
||||
priceMin: $priceMin,
|
||||
priceMax: $priceMax,
|
||||
intent: $intent,
|
||||
needsLlmFallback: false,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalize(string $prompt): string
|
||||
{
|
||||
$value = mb_strtolower(trim($prompt));
|
||||
$value = str_replace(['€'], ' euro ', $value);
|
||||
$value = preg_replace('/[^\p{L}\p{N}\s.,\-]/u', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:?float,1:?float}
|
||||
*/
|
||||
private function extractPriceRange(string $prompt): array
|
||||
{
|
||||
$priceMin = 10;
|
||||
$priceMax = null;
|
||||
|
||||
if (preg_match('/\bzwischen\s+(\d+(?:[.,]\d+)?)\s+und\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
|
||||
$a = $this->toFloat($m[1]);
|
||||
$b = $this->toFloat($m[2]);
|
||||
|
||||
if ($a !== null && $b !== null) {
|
||||
return [min($a, $b), max($a, $b)];
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:unter|bis|max(?:imal)?)\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
|
||||
$priceMax = $this->toFloat($m[1]);
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:ab|mindestens|min)\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
|
||||
$priceMin = $this->toFloat($m[1]);
|
||||
}
|
||||
|
||||
return [$priceMin, $priceMax];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractSizes(string $prompt): array
|
||||
{
|
||||
$sizes = [];
|
||||
|
||||
if (preg_match_all('/\b(?:größe|groesse|grösse)\s*([a-z0-9.-]+)\b/u', $prompt, $matches) === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($matches[1] as $size) {
|
||||
$sizes[] = trim($size);
|
||||
}
|
||||
|
||||
if (preg_match_all('/\b(xs|s|m|l|xl|xxl|xxxl)\b/u', $prompt, $tokenMatches) !== false) {
|
||||
foreach ($tokenMatches[1] as $sizeToken) {
|
||||
$sizes[] = trim($sizeToken);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($sizes, static fn ($v) => $v !== '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractColors(string $prompt): array
|
||||
{
|
||||
$colors = [];
|
||||
|
||||
foreach ($this->knownColors as $color) {
|
||||
if (preg_match('/\b' . preg_quote($color, '/') . '\b/u', $prompt) === 1) {
|
||||
$colors[] = $color;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($colors));
|
||||
}
|
||||
|
||||
private function extractBrand(string $prompt): ?string
|
||||
{
|
||||
foreach ($this->knownBrands as $brand) {
|
||||
if (str_contains($prompt, $brand)) {
|
||||
return $brand;
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/\bmarke\s+([a-z0-9][a-z0-9\s\-]+)/u', $prompt, $m) === 1) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractCategory(string $prompt): ?string
|
||||
{
|
||||
foreach ($this->knownCategories as $category) {
|
||||
if (preg_match('/\b' . preg_quote($category, '/') . '\b/u', $prompt) === 1) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildSearchText(
|
||||
string $prompt,
|
||||
array $colors,
|
||||
array $sizes,
|
||||
?string $brand,
|
||||
?float $priceMin,
|
||||
?float $priceMax
|
||||
): string {
|
||||
$text = ' ' . $prompt . ' ';
|
||||
|
||||
$phrasesToRemove = [
|
||||
'ich suche',
|
||||
'suche',
|
||||
'habt ihr',
|
||||
'gibt es',
|
||||
'zeige mir',
|
||||
'welches gerät',
|
||||
'welche gerät',
|
||||
'welches modell',
|
||||
'welches ist besser',
|
||||
'welches ist am besten',
|
||||
'alternative',
|
||||
'alternativen',
|
||||
];
|
||||
|
||||
foreach ($phrasesToRemove as $phrase) {
|
||||
$text = str_replace($phrase, ' ', $text);
|
||||
}
|
||||
|
||||
foreach ($colors as $color) {
|
||||
$text = preg_replace('/\b' . preg_quote($color, '/') . '\b/u', ' ', $text) ?? $text;
|
||||
}
|
||||
|
||||
foreach ($sizes as $size) {
|
||||
$text = preg_replace('/\b' . preg_quote($size, '/') . '\b/u', ' ', $text) ?? $text;
|
||||
}
|
||||
|
||||
if ($brand !== null && $brand !== '') {
|
||||
$text = str_replace($brand, ' ', $text);
|
||||
}
|
||||
|
||||
if ($priceMin !== null || $priceMax !== null) {
|
||||
if ($priceMin !== null || $priceMax !== null) {
|
||||
$text = preg_replace('/\bzwischen\s+\d+(?:[.,]\d+)?\s+und\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text;
|
||||
$text = preg_replace('/\b(?:unter|bis|max(?:imal)?|ab|mindestens|min)\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text;
|
||||
$text = preg_replace('/\beuro\b/u', ' ', $text) ?? $text;
|
||||
}
|
||||
}
|
||||
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
$text = trim($text, " \t\n\r\0\x0B-.,");
|
||||
$tokens = array_filter(explode(' ', $text), static fn (string $token): bool => mb_strlen($token) > 1);
|
||||
|
||||
return trim(implode(' ', $tokens));
|
||||
}
|
||||
|
||||
private function toFloat(string $value): ?float
|
||||
{
|
||||
$value = str_replace(',', '.', trim($value));
|
||||
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
}
|
||||
}
|
||||
29
src/Commerce/Dto/CommerceSearchQuery.php
Normal file
29
src/Commerce/Dto/CommerceSearchQuery.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce\Dto;
|
||||
|
||||
final readonly class CommerceSearchQuery
|
||||
{
|
||||
/**
|
||||
* @param string[] $colors
|
||||
* @param string[] $sizes
|
||||
* @param string[] $properties
|
||||
*/
|
||||
public function __construct(
|
||||
public string $originalPrompt,
|
||||
public string $normalizedPrompt,
|
||||
public string $searchText,
|
||||
public ?string $category = null,
|
||||
public ?string $brand = null,
|
||||
public array $colors = [],
|
||||
public array $sizes = [],
|
||||
public array $properties = [],
|
||||
public ?float $priceMin = null,
|
||||
public ?float $priceMax = null,
|
||||
public string $intent = 'none',
|
||||
public bool $needsLlmFallback = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
24
src/Commerce/Dto/ShopProductResult.php
Normal file
24
src/Commerce/Dto/ShopProductResult.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce\Dto;
|
||||
|
||||
final readonly class ShopProductResult
|
||||
{
|
||||
/**
|
||||
* @param string[] $highlights
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public ?string $productNumber = null,
|
||||
public ?string $manufacturer = null,
|
||||
public ?string $price = null,
|
||||
public ?bool $available = null,
|
||||
public ?string $url = null,
|
||||
public array $highlights = [],
|
||||
public ?string $description = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
154
src/Commerce/ShopSearchService.php
Normal file
154
src/Commerce/ShopSearchService.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce;
|
||||
|
||||
use App\Commerce\Dto\ShopProductResult;
|
||||
use App\Shopware\ShopwareCriteriaBuilder;
|
||||
use App\Shopware\StoreApiClient;
|
||||
|
||||
final readonly class ShopSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private CommerceQueryParser $queryParser,
|
||||
private ShopwareCriteriaBuilder $criteriaBuilder,
|
||||
private StoreApiClient $storeApiClient,
|
||||
private bool $enabled = true,
|
||||
private int $maxResults = 25,
|
||||
private string $baseUrl
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShopProductResult[]
|
||||
*/
|
||||
public function search(string $originalPrompt, string $commerceIntent): array
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = $this->queryParser->parse($originalPrompt, $commerceIntent);
|
||||
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
||||
$response = $this->storeApiClient->searchProducts($criteria);
|
||||
|
||||
return $this->mapProducts($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;
|
||||
}
|
||||
|
||||
$results[] = new ShopProductResult(
|
||||
id: (string)($row['id'] ?? ''),
|
||||
name: trim((string)($row['translated']['name'] ?? '')),
|
||||
productNumber: isset($row['productNumber']) ? (string)$row['productNumber'] : null,
|
||||
price: $this->extractPrice($row),
|
||||
available: isset($row['available']) ? (bool)$row['available'] : null,
|
||||
url: $this->baseUrl . $this->extractUrl($row),
|
||||
highlights: $this->extractHighlights($row),
|
||||
description: $this->cleanUpDescription($row),
|
||||
);
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$results,
|
||||
static fn(ShopProductResult $product): bool => $product->name !== ''
|
||||
));
|
||||
}
|
||||
|
||||
private function cleanUpDescription($description): string
|
||||
{
|
||||
if (isset($description['translated']['description'])) {
|
||||
$newDesc = strip_tags((string)$description['translated']['description']);
|
||||
$newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc); // leere Zeilen weg
|
||||
$newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc); // mehrere Spaces zu einem
|
||||
$result = trim($newDesc);
|
||||
return substr($result, 0, 500);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractManufacturer(array $row): ?string
|
||||
{
|
||||
$manufacturer = $row['manufacturer'] ?? null;
|
||||
|
||||
if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) {
|
||||
return trim($manufacturer['name']) !== '' ? trim($manufacturer['name']) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractPrice(array $row): ?string
|
||||
{
|
||||
$calculatedPrice = $row['calculatedPrice'] ?? null;
|
||||
|
||||
if (!is_array($calculatedPrice)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$unitPrice = $calculatedPrice['unitPrice'] ?? $calculatedPrice['totalPrice'] ?? $calculatedPrice['referencePrice'] ?? $calculatedPrice['listPrice'] ?? $calculatedPrice['regulationPrice'] ?? 0;
|
||||
if (!is_numeric($unitPrice)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return number_format((float)$unitPrice, 2, ',', '.') . ' €';
|
||||
}
|
||||
|
||||
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 '/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractHighlights(array $row): array
|
||||
{
|
||||
$highlights = [];
|
||||
|
||||
if (isset($row['available'])) {
|
||||
$highlights[] = ((bool)$row['available']) ? 'Verfügbar' : 'Nicht verfügbar';
|
||||
}
|
||||
|
||||
if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') {
|
||||
$highlights[] = 'Produktnummer: ' . trim($row['productNumber']);
|
||||
}
|
||||
|
||||
return array_values(array_unique($highlights));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user