Реализация приоритизации поставщиков (цена/наличие) при автонаполнении
Когда один и тот же товар есть у нескольких поставщиков, нужны правила: у кого заказать, чья цена ляжет в карточку, чьи данные считать основными. Без явных правил приоритизации каталог превращается в хаос — цены прыгают, фото меняются при каждом импорте, нет предсказуемости для покупателя.
Уровни приоритетов
Приоритизация работает на нескольких уровнях одновременно:
| Уровень | Что определяет | Пример |
|---|---|---|
| Контент | Чьё название, описание, фото использовать | Поставщик 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 рабочих дней.







