Реалізація автоматичного створення карток товарів зі спарсених даних
Автоматичне створення карток — завершальний етап конвеєру: спарсені дані від постачальників або маркетплейсів перетворюються на повноцінні карти товарів у магазині. Завдання вимагає нормалізації даних, порівняння з існуючим каталогом, створення варіативних товарів та публікації медіа.
Загальна схема конвеєру
Raw Scraped Data (JSON)
↓
DataValidator – перевірка обов'язкових полів
↓
DataNormalizer – нормалізація полів, одиниць, форматів
↓
CategoryMatcher – визначення категорії за даними
↓
DuplicateChecker – перевірка, чи не існує вже такий товар
↓
VariantBuilder – збірка варіативних товарів з розмірів/кольорів
↓
ImageProcessor – завантаження та обробка фотографій
↓
ProductCreator – запис у БД через репозиторій платформи
↓
SEOGenerator – заповнення мета-тегів, slug
↓
PublicationDecider – draft / published залежно від налаштувань
Валідація вхідних даних
// app/Services/ProductImport/DataValidator.php
use Illuminate\Support\Facades\Validator;
class DataValidator
{
private array $rules = [
'sku' => 'required|string|max:100',
'name' => 'required|string|max:500',
'price' => 'required|numeric|min:0.01|max:9999999',
'description' => 'nullable|string',
'images' => 'nullable|array',
'images.*' => 'nullable|url',
'specs' => 'nullable|array',
'in_stock' => 'nullable|boolean',
];
public function validate(array $data): ValidationResult
{
$validator = Validator::make($data, $this->rules);
if ($validator->fails()) {
return ValidationResult::fail($validator->errors()->toArray());
}
// Додаткові бізнес-правила
if (isset($data['price']) && $data['price'] < config('import.min_price', 1)) {
return ValidationResult::fail(['price' => ['Ціна підозріло низька']]);
}
return ValidationResult::pass($validator->validated());
}
}
Порівняння категорій
// app/Services/ProductImport/CategoryMatcher.php
class CategoryMatcher
{
public function match(array $productData): ?int
{
// Стратегія 1: за маппінгом постачальник → категорія
if ($supplierId = $productData['supplier_id'] ?? null) {
$mapping = SupplierCategoryMapping::where('supplier_id', $supplierId)
->where('supplier_category', $productData['category'] ?? '')
->first();
if ($mapping) return $mapping->local_category_id;
}
// Стратегія 2: нечіткий пошук за назвою категорії
if ($categoryName = $productData['category'] ?? null) {
$category = Category::where('name', 'ilike', "%{$categoryName}%")->first();
if ($category) return $category->id;
}
// Стратегія 3: за ключовими словами в назві товару
return $this->matchByKeywords($productData['name']);
}
private function matchByKeywords(string $name): ?int
{
$rules = CategoryKeywordRule::orderBy('priority', 'desc')->get();
foreach ($rules as $rule) {
foreach ($rule->keywords as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return $rule->category_id;
}
}
}
return config('import.default_category_id');
}
}
Детектор дублів
// app/Services/ProductImport/DuplicateChecker.php
class DuplicateChecker
{
public function findExisting(array $data): ?Product
{
// Точне совпадіння за SKU постачальника
if ($supplierId = $data['supplier_id'] ?? null) {
$existing = Product::whereHas('supplierMappings', function ($q) use ($data, $supplierId) {
$q->where('supplier_id', $supplierId)
->where('supplier_sku', $data['sku']);
})->first();
if ($existing) return $existing;
}
// Совпадіння за EAN/GTIN
if ($gtin = $data['gtin'] ?? null) {
$existing = Product::where('gtin', $gtin)->first();
if ($existing) return $existing;
}
// Нечітке совпадіння за назвою + брендом (поріг 90%)
if (($name = $data['name'] ?? null) && ($brand = $data['brand'] ?? null)) {
$candidates = Product::where('brand', $brand)
->get(['id', 'name']);
foreach ($candidates as $candidate) {
similar_text(
mb_strtolower($name),
mb_strtolower($candidate->name),
$pct
);
if ($pct >= 90) return $candidate;
}
}
return null;
}
}
Будівельник варіативних товарів
// app/Services/ProductImport/VariantBuilder.php
class VariantBuilder
{
/**
* Групує плоский список товарів у configurable products з варіантами
*
* Приклад: 3 рядки "Футболка Nike S/червоний", "S/синій", "M/червоний"
* → 1 configurable product + 3 simple variants
*/
public function buildVariants(array $products): array
{
// Групуємо за базовою назвою (без розміру/кольору)
$groups = [];
foreach ($products as $product) {
$baseKey = $this->extractBaseKey($product);
$groups[$baseKey][] = $product;
}
$result = [];
foreach ($groups as $baseKey => $variants) {
if (count($variants) === 1) {
$result[] = ['type' => 'simple', 'data' => $variants[0]];
} else {
$result[] = [
'type' => 'configurable',
'base' => $this->buildBase($variants),
'variants' => $variants,
];
}
}
return $result;
}
private function extractBaseKey(array $product): string
{
// Прибираємо з імені паттерни розмірів та кольорів
$name = preg_replace('/\b(xs|s|m|l|xl|xxl|\d+\s*(см|мм|дюйм))/iu', '', $product['name']);
$name = preg_replace('/\b(червоний|синій|чорний|білий|зелений|red|blue|black|white)/iu', '', $name);
// Групуємо за SKU-префіксом, якщо доступний
if (preg_match('/^([A-Z0-9]+)-/i', $product['sku'], $m)) {
return strtoupper($m[1]);
}
return trim($product['brand'] ?? '') . '|' . trim($name);
}
}
Основний сервіс створення картки
// app/Services/ProductImport/ProductCreator.php
class ProductCreator
{
public function create(array $data, string $type = 'simple'): Product
{
$slug = $this->generateUniqueSlug($data['name']);
$product = Product::create([
'type' => $type,
'sku' => $data['sku'],
'name' => $data['name'],
'slug' => $slug,
'description' => $data['description'] ?? '',
'price' => $data['price'],
'brand' => $data['brand'] ?? null,
'gtin' => $data['gtin'] ?? null,
'category_id' => $data['category_id'],
'status' => $data['auto_publish'] ? 'active' : 'draft',
'meta_title' => $this->generateMetaTitle($data),
'meta_description' => $this->generateMetaDesc($data),
]);
// Прив'язка до постачальника
if ($supplierId = $data['supplier_id'] ?? null) {
$product->supplierMappings()->create([
'supplier_id' => $supplierId,
'supplier_sku' => $data['supplier_sku'] ?? $data['sku'],
'supplier_url' => $data['source_url'] ?? null,
]);
}
// Характеристики
if (!empty($data['specs'])) {
foreach ($data['specs'] as $key => $value) {
$attribute = ProductAttribute::firstOrCreate(['code' => $this->slug($key)], ['name' => $key]);
$product->attributeValues()->create([
'attribute_id' => $attribute->id,
'value' => $value,
]);
}
}
// Зображення (через Job)
if (!empty($data['images'])) {
DownloadAndAttachProductImages::dispatch($product->id, $data['images'])
->onQueue('image-processing');
}
return $product;
}
private function generateUniqueSlug(string $name): string
{
$base = \Str::slug($name);
$slug = $base;
$i = 1;
while (Product::where('slug', $slug)->exists()) {
$slug = "{$base}-{$i}";
$i++;
}
return $slug;
}
private function generateMetaTitle(array $data): string
{
$title = $data['name'];
if ($brand = $data['brand'] ?? null) {
$title = "{$brand} {$title}";
}
return mb_substr($title, 0, 70);
}
}
Оркестрування завдання
// app/Jobs/CreateProductFromScrapedData.php
class CreateProductFromScrapedData implements ShouldQueue
{
public int $tries = 3;
public function handle(
DataValidator $validator,
CategoryMatcher $matcher,
DuplicateChecker $checker,
ProductCreator $creator
): void {
// Валідація
$result = $validator->validate($this->rawData);
if (!$result->passes()) {
ImportLog::create([
'source' => $this->source,
'sku' => $this->rawData['sku'] ?? 'unknown',
'status' => 'validation_failed',
'errors' => $result->errors(),
]);
return;
}
$data = $result->data();
// Перевірка дублів
if ($existing = $checker->findExisting($data)) {
// Оновлюємо існуючий товар, не створюємо дубль
$existing->update(['price' => $data['price'], 'in_stock' => $data['in_stock']]);
ImportLog::create(['status' => 'updated', 'product_id' => $existing->id]);
return;
}
// Порівняння категорії
$data['category_id'] = $matcher->match($data);
// Створення
$product = $creator->create($data);
ImportLog::create([
'status' => 'created',
'product_id' => $product->id,
'source' => $this->source,
]);
}
}
Тривалість розробки
| Компонент | Тривалість |
|---|---|
| Валідатор + нормалізатор | 1–2 дні |
| Порівняння категорій | 1–2 дні |
| Детектор дублів | 1 день |
| Будівельник варіантів | 2–3 дні |
| Творець карток + SEO | 1–2 дні |
| Логування + панель імпорту | 1–2 дні |
| Загалом | 7–12 робочих днів |







