Реалізація агрегації товарів від декількох поставників на сайті
Агрегація — це не просто злиття списків. Це створення єдиної картки товару на основі даних від декількох поставників зі збереженням зв'язку з кожним джерелом. Мета: покупець бачить одну картку, але за нею стоїть актуальний вибір з кількох пропозицій з різними цінами, строками та наявністю.
Різниця між імпортом та агрегацією
Імпорт — збереження «як є». Агрегація — побудова вітрини над сирими даними кількох поставників.
При агрегації потрібно розв'язати три завдання:
-
Розпізнавання — зрозуміти, що
SKU-447у поставника A іART-10023у поставника B — один і той же товар - Злиття — обрати, чиї атрибути (назва, фото, опис) вважати основними
- Вітрина цін — показати покупцеві найкращу пропозицію або дати вибір
Схема даних для агрегації
-- Мастер-картка (агрегована)
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
master_sku VARCHAR(255) UNIQUE NOT NULL,
name TEXT NOT NULL, -- від «головного» поставника
description TEXT,
attributes JSONB DEFAULT '{}',
category_id INT REFERENCES categories(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Пропозиції поставників до мастер-картки
CREATE TABLE product_offers (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id),
supplier_id INT NOT NULL REFERENCES suppliers(id),
supplier_sku VARCHAR(255) NOT NULL,
price NUMERIC(12,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
lead_time_days SMALLINT, -- строк поставки
is_primary BOOLEAN DEFAULT FALSE, -- джерело контенту для картки
last_synced_at TIMESTAMP,
UNIQUE(supplier_id, supplier_sku)
);
-- Індекси для швидкого пошуку найкращої пропозиції
CREATE INDEX idx_offers_product_price ON product_offers(product_id, price)
WHERE stock > 0;
Логіка вибору «найкращої пропозиції»
Найкраща пропозиція визначається за настроюваними правилами. Типовий варіант: мінімальна ціна серед поставників з наявністю.
class BestOfferResolver
{
public function resolve(int $productId): ?ProductOffer
{
return ProductOffer::where('product_id', $productId)
->where('stock', '>', 0)
->orderByRaw('
price * (1 + COALESCE(
(SELECT markup FROM suppliers WHERE id = supplier_id), 0
) / 100)
')
->orderBy('lead_time_days')
->first();
}
}
Більш гнучкий підхід — скоринг через зважені критерії:
class WeightedOfferScorer
{
// Настройки ваг з конфігурації магазину
private float $priceWeight = 0.60;
private float $stockWeight = 0.25;
private float $leadTimeWeight = 0.15;
public function score(ProductOffer $offer, array $stats): float
{
// Нормалізація: найкраща отримує 1.0
$priceScore = $stats['min_price'] / max($offer->price, 0.01);
$stockScore = min($offer->stock / 100, 1.0);
$leadScore = $stats['max_lead'] > 0
? 1 - ($offer->lead_time_days / $stats['max_lead'])
: 1.0;
return $this->priceWeight * $priceScore
+ $this->stockWeight * $stockScore
+ $this->leadTimeWeight * $leadScore;
}
}
Агрегатна вітрина в API
Відповідь API для картки товару повинна включати агреговані дані:
class ProductResource extends JsonResource
{
public function toArray($request): array
{
$bestOffer = $this->bestOffer;
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'attributes' => $this->attributes,
// Агреговані ціни
'price' => $bestOffer?->price,
'price_min' => $this->offers->where('stock', '>', 0)->min('price'),
'price_max' => $this->offers->where('stock', '>', 0)->max('price'),
'in_stock' => $this->offers->where('stock', '>', 0)->count() > 0,
'total_stock' => $this->offers->sum('stock'),
// Список пропозицій (якщо магазин показує їх явно)
'offers' => OfferResource::collection(
$this->offers->where('stock', '>', 0)->sortBy('price')
),
];
}
}
Оновлення агрегації при зміні пропозицій
Агреговані показники повинні оновлюватися при кожній зміні пропозиції поставника. Через Observer:
class ProductOfferObserver
{
public function saved(ProductOffer $offer): void
{
// Пересчёт агрегатів у кеші
Cache::forget("product.{$offer->product_id}.best_offer");
Cache::forget("product.{$offer->product_id}.price_range");
// Оновлення денормалізованих полів у products
$this->recalculate($offer->product_id);
}
private function recalculate(int $productId): void
{
$agg = ProductOffer::where('product_id', $productId)
->where('stock', '>', 0)
->selectRaw('MIN(price) as min_price, MAX(price) as max_price, SUM(stock) as total_stock')
->first();
Product::where('id', $productId)->update([
'price_min' => $agg->min_price,
'price_max' => $agg->max_price,
'total_stock' => $agg->total_stock,
'updated_at' => now(),
]);
}
}
Відображення кількох пропозицій на картці
Якщо бізнес-логіка передбачає вибір поставника покупцем (як у Yandex.Market):
// React-компонент списку пропозицій
const OfferList: React.FC<{ offers: Offer[] }> = ({ offers }) => {
const sorted = [...offers].sort((a, b) => a.price - b.price);
return (
<div className="space-y-2">
{sorted.map(offer => (
<div key={offer.id} className="flex items-center justify-between border rounded p-3">
<div>
<span className="font-semibold">{formatPrice(offer.price)}</span>
<span className="text-sm text-gray-500 ml-2">
{offer.supplier.name}
</span>
</div>
<div className="text-sm text-gray-500">
{offer.stock > 0
? `у наявності ${offer.stock} шт.`
: 'немає у наявності'}
{offer.lead_time_days && ` · доставка ${offer.lead_time_days} дн.`}
</div>
<button
onClick={() => addToCart(offer)}
disabled={offer.stock === 0}
className="btn-primary"
>
Купити
</button>
</div>
))}
</div>
);
};
Інвалідація кеша та Elasticsearch
При великому каталозі (50 000+ товарів) агрегати часто зберігаються в Elasticsearch — це прискорює фільтрацію за ціною, наявністю, поставником. При зміні пропозиції потрібно переіндексувати документ:
ProductOffer::saved(function ($offer) {
ReindexProductJob::dispatch($offer->product_id);
});
У маппінгу Elasticsearch пропозиції зберігаються як nested об'єкти, що дозволяє фільтрувати за конкретними комбінаціями атрибутів поставника.
Графік реалізації
- Схема даних + логіка злиття + BestOfferResolver: 2 дні
- Observer + денормалізація агрегатів: 1 день
- API-ресурс з пропозиціями + фронтенд компонент: 1–2 дні
- Інтеграція з Elasticsearch: +2 дні
- Настройка коефіцієнтів ваг через админку: +1 день
Базова агрегація без пошуку: 4–5 робочих днів.







