Реалізація приоритизації постачальників (ціна/наявність) при автозаповненні
Коли один і той же товар є у кількох постачальників, потрібні правила: у кого замовляти, чия ціна йде в карточку, чиї дані вважати основними. Без явних правил приоритизації каталог перетворюється на хаос — ціни стрибають, фото змінюються при кожному імпорті, немає передбачуваності для покупця.
Рівні пріоритетів
Приоритизація працює на кількох рівнях одночасно:
| Рівень | Що визначає | Приклад |
|---|---|---|
| Контент | Чию назву, опис, фото використовувати | Постачальник A має кращий контент |
| Ціна | Яку ціну показувати покупцеві | Мінімум серед постачальників з наявністю |
| Замовлення | У кого фактично розміщувати замовлення | Найдешевший, потім резерв |
| Наявність | Як рахувати сумарний запас | Сума, або тільки основного постачальника |
Модель конфігурації пріоритетів
CREATE TABLE supplier_priority_rules (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
scope_type VARCHAR(20) NOT NULL, -- 'global', 'category', 'brand', 'product'
scope_id BIGINT, -- NULL для global
price_strategy VARCHAR(30) NOT NULL, -- 'min', 'primary', 'markup'
content_mode VARCHAR(20) NOT NULL, -- 'primary_first', 'best_score'
order_mode VARCHAR(20) NOT NULL, -- 'cheapest', 'priority_rank', 'round_robin'
stock_mode VARCHAR(20) NOT NULL, -- 'sum', 'primary_only', 'max'
is_active BOOLEAN DEFAULT TRUE,
priority INT DEFAULT 0 -- пріоритет правила (вище = важливіше)
);
-- Ранги постачальників у контексті правила
CREATE TABLE supplier_rule_ranks (
rule_id BIGINT REFERENCES supplier_priority_rules(id),
supplier_id INT REFERENCES suppliers(id),
rank SMALLINT NOT NULL, -- 1 = найвищий пріоритет
markup_pct NUMERIC(5,2) DEFAULT 0, -- наценка до ціни постачальника
is_content_src BOOLEAN DEFAULT FALSE, -- джерело контенту
PRIMARY KEY (rule_id, supplier_id)
);
Стратегії ціноутворення
enum PriceStrategy: string
{
case Min = 'min'; // Мінімальна ціна серед постачальників з наявністю
case Primary = 'primary'; // Ціна основного постачальника
case Markup = 'markup'; // Базова ціна + наценка з правила
}
class PriceResolver
{
public function resolve(Product $product, PriorityRule $rule): ?float
{
$offers = $product->offers()
->where('stock', '>', 0)
->with('supplier')
->get();
return match ($rule->price_strategy) {
PriceStrategy::Min->value => $this->resolveMin($offers, $rule),
PriceStrategy::Primary->value => $this->resolvePrimary($offers, $rule),
PriceStrategy::Markup->value => $this->resolveWithMarkup($offers, $rule),
};
}
private function resolveMin(Collection $offers, PriorityRule $rule): ?float
{
// Враховуємо наценку кожного постачальника при обчисленні мінімуму
return $offers->map(function ($offer) use ($rule) {
$rank = $rule->ranks->firstWhere('supplier_id', $offer->supplier_id);
$markup = $rank?->markup_pct ?? 0;
return $offer->price * (1 + $markup / 100);
})->min();
}
private function resolvePrimary(Collection $offers, PriorityRule $rule): ?float
{
// Основний постачальник — перший за рангом з наявністю
$rankedOffers = $offers->sortBy(function ($offer) use ($rule) {
$rank = $rule->ranks->firstWhere('supplier_id', $offer->supplier_id);
return $rank?->rank ?? PHP_INT_MAX;
});
$primaryOffer = $rankedOffers->first();
if (!$primaryOffer) return null;
$rank = $rule->ranks->firstWhere('supplier_id', $primaryOffer->supplier_id);
return $primaryOffer->price * (1 + ($rank?->markup_pct ?? 0) / 100);
}
}
Стратегії вибору джерела контенту
class ContentSourceResolver
{
public function resolveContentSupplier(Product $product, PriorityRule $rule): ?int
{
return match ($rule->content_mode) {
'primary_first' => $this->primaryFirst($product, $rule),
'best_score' => $this->bestScore($product, $rule),
default => null,
};
}
private function primaryFirst(Product $product, PriorityRule $rule): ?int
{
// Беремо постачальника з is_content_src = true, якщо він має пропозицію
$contentSupplierIds = $rule->ranks
->where('is_content_src', true)
->sortBy('rank')
->pluck('supplier_id');
foreach ($contentSupplierIds as $supplierId) {
if ($product->offers->firstWhere('supplier_id', $supplierId)) {
return $supplierId;
}
}
// Fallback: перший за рангом з наявністю
return $product->offers
->sortBy(fn($o) => $rule->ranks->firstWhere('supplier_id', $o->supplier_id)?->rank ?? 999)
->first()?->supplier_id;
}
private function bestScore(Product $product, PriorityRule $rule): ?int
{
// Скоринг повноти контенту постачальника
return $product->offers->sortByDesc(function ($offer) {
$sp = SupplierProduct::where([
'supplier_id' => $offer->supplier_id,
'external_id' => $offer->supplier_sku,
])->first();
if (!$sp) return 0;
$score = 0;
if (!empty($sp->attributes['description'])) $score += 30;
if (!empty($sp->attributes['images'])) $score += 25;
if (!empty($sp->attributes['brand'])) $score += 15;
if (mb_strlen($sp->name) > 50) $score += 10;
if (!empty($sp->attributes['specs'])) $score += 20;
return $score;
})->first()?->supplier_id;
}
}
Застосування правил
class ProductSyncService
{
public function syncProduct(Product $product): void
{
$rule = $this->ruleResolver->findApplicableRule($product);
if (!$rule) return;
// Ціна
$newPrice = $this->priceResolver->resolve($product, $rule);
// Наявність
$newStock = match ($rule->stock_mode) {
'sum' => $product->offers->sum('stock'),
'primary_only' => $this->getPrimaryOffer($product, $rule)?->stock ?? 0,
'max' => $product->offers->max('stock'),
};
// Контент
$contentSupplierId = $this->contentResolver->resolveContentSupplier($product, $rule);
$product->update([
'price' => $newPrice,
'stock' => $newStock,
'content_supplier' => $contentSupplierId,
]);
if ($contentSupplierId) {
$this->applySupplierContent($product, $contentSupplierId);
}
}
}
Розв'язання конфліктів при однакових цінах
Якщо кілька постачальників дають однакову ціну — порядок переваги:
- Постачальник з найменшим часом доставки (
lead_time_days) - Постачальник з більшим остатком
- Постачальник з найвищим рейтингом надійності (відсоток успішних замовлень)
- Ранг у таблиці
supplier_rule_ranks
Інтерфейс управління правилами
В адміністративній панелі правила повинні бути налаштовними без розгортання:
- Вибір області дії (глобально, по категорії, по бренду)
- Drag-and-drop ранжування постачальників
- Перемикачі стратегій (min/primary/markup)
- Поле наценки для кожного постачальника
- Тестування правила на конкретному товарі
Строки реалізації
- Схема даних + моделі: 1 день
- PriceResolver + ContentSourceResolver: 1–2 дні
- ProductSyncService + Observer-тригери: 1 день
- Інтерфейс управління правилами в админці: 2 дні
- Тести + документація для оператора: 1 день
Разом: 6–7 робочих днів.







