Реализация автоматического обновления цен товаров из внешних источников
Интернет-магазины, работающие с несколькими поставщиками или использующие динамическое ценообразование, сталкиваются с одной и той же проблемой: цены в каталоге устаревают быстрее, чем их успевают обновить вручную. Решение — построить автоматический pipeline, который тянет актуальные данные из внешних источников и обновляет цены в БД без участия оператора.
Источники цен и способы их получения
Внешними источниками цен могут быть:
- Прайс-листы поставщиков — CSV/Excel-файлы по HTTP или FTP
- API поставщиков — REST или SOAP с авторизацией по токену
-
YML-фиды — формат Яндекс.Маркета, содержит
<price>и<oldprice> -
Google Merchant Feed — XML с полем
g:price - Парсинг страниц — крайний случай, когда нет ни API, ни фида
Для каждого типа источника нужен отдельный адаптер, реализующий общий интерфейс:
interface PriceSourceInterface
{
/** @return array<string, float> [sku => price] */
public function fetch(): array;
}
Адаптер для CSV по HTTP
class CsvHttpPriceSource implements PriceSourceInterface
{
public function __construct(
private string $url,
private int $skuColumn,
private int $priceColumn,
private string $delimiter = ';',
) {}
public function fetch(): array
{
$stream = fopen($this->url, 'r');
$prices = [];
$header = fgetcsv($stream, 0, $this->delimiter); // пропускаем заголовок
while ($row = fgetcsv($stream, 0, $this->delimiter)) {
$sku = trim($row[$this->skuColumn]);
$price = (float) str_replace(',', '.', $row[$this->priceColumn]);
if ($sku && $price > 0) {
$prices[$sku] = $price;
}
}
fclose($stream);
return $prices;
}
}
Архитектура планировщика
Обновление цен — это фоновая задача. Стандартный подход в Laravel: Artisan-команда + scheduler + Queue.
Cron (каждые N минут)
└─> SchedulePriceUpdateCommand
└─> PriceUpdateJob (queued)
└─> PriceSourceFactory::make($source)
└─> PriceUpdater::apply($prices)
Команда-диспетчер:
class SchedulePriceUpdateCommand extends Command
{
protected $signature = 'prices:update {--source=all}';
public function handle(PriceSourceRepository $repo): void
{
$sources = $this->option('source') === 'all'
? $repo->getActive()
: [$repo->find($this->option('source'))];
foreach ($sources as $source) {
PriceUpdateJob::dispatch($source)->onQueue('prices');
}
}
}
В app/Console/Kernel.php:
$schedule->command('prices:update')->everyThirtyMinutes();
Логика обновления с защитой от мусора
Нельзя слепо писать любую цену из фида. Нужны проверки:
| Проверка | Причина |
|---|---|
price > 0 |
Поставщик может прислать 0 при ошибке |
abs(new - old) / old < 0.5 |
Изменение >50% — скорее всего сбой |
| SKU существует в каталоге | Не создавать "призрачные" товары |
| Источник не устарел (TTL) | Фид мог не обновиться |
class PriceUpdater
{
private const MAX_CHANGE_RATIO = 0.5;
public function apply(array $prices, PriceSource $source): UpdateResult
{
$updated = $skipped = $errors = 0;
foreach ($prices as $sku => $newPrice) {
$product = Product::where('sku', $sku)->first();
if (!$product) { $skipped++; continue; }
$oldPrice = $product->price;
if ($oldPrice > 0) {
$ratio = abs($newPrice - $oldPrice) / $oldPrice;
if ($ratio > self::MAX_CHANGE_RATIO) {
Log::warning("Price anomaly: $sku $oldPrice -> $newPrice");
$errors++;
continue;
}
}
$product->update([
'price' => $newPrice,
'price_updated_at' => now(),
'price_source_id' => $source->id,
]);
$updated++;
}
return new UpdateResult($updated, $skipped, $errors);
}
}
Множественные источники и приоритеты
Когда товар присутствует в нескольких фидах, нужна стратегия разрешения конфликтов:
- MIN — брать минимальную цену (агрессивное ценообразование)
- PRIMARY — первый источник в приоритете, остальные как fallback
- LAST_UPDATED — цена из последнего обновлённого фида
Конфигурация источника в БД:
CREATE TABLE price_sources (
id serial PRIMARY KEY,
name varchar(100),
type varchar(30), -- csv_http | api | yml | merchant
config jsonb, -- url, credentials, column mapping
priority smallint DEFAULT 10,
strategy varchar(20) DEFAULT 'primary',
active boolean DEFAULT true,
updated_at timestamptz
);
Обновление через API поставщика
Если поставщик предоставляет REST API с пагинацией:
class ApiPriceSource implements PriceSourceInterface
{
public function fetch(): array
{
$client = new \GuzzleHttp\Client(['base_uri' => $this->baseUrl]);
$prices = [];
$page = 1;
do {
$response = $client->get('/v2/prices', [
'headers' => ['Authorization' => 'Bearer ' . $this->token],
'query' => ['page' => $page, 'per_page' => 500],
]);
$data = json_decode($response->getBody(), true);
foreach ($data['items'] as $item) {
$prices[$item['article']] = (float) $item['price_rub'];
}
$page++;
} while ($data['has_more']);
return $prices;
}
}
Сроки реализации
- Базовый pipeline (один CSV-источник, scheduler, обновление в БД) — 2–3 дня
- Поддержка нескольких типов источников + приоритеты — +2 дня
- Дашборд с историей обновлений и алертами на аномалии — +2 дня
Мониторинг и алерты
После каждого цикла обновления стоит писать в таблицу price_update_logs:
source_id | total_fetched | updated | skipped | errors | duration_ms | created_at
При errors / total_fetched > 0.05 (более 5% аномалий) — отправлять уведомление в Slack или на email через Laravel Notification. Это позволяет обнаружить сломавшийся фид до того, как покупатели увидят некорректные цены.







