Реалізація мультипоставницького імпорту товарів
Коли каталог формується з трьох або більше поставників одночасно, просте «завантажити файл» перестає працювати. Виникають конфлікти — однакові артикули з різними цінами, дублікати з різними SKU, поставники з різними форматами даних. Мультипоставницький імпорт вимагає уніфікованого конвеєру з явними правилами розв'язання конфліктів.
Архітектура конвеєру
Поставник A (XML) ─┐
Поставник B (CSV) ─┤─► Нормалізатор ─► Дедублікатор ─► Мержер ─► Каталог БД
Поставник C (API) ─┘
Кожен поставник — окремий адаптер, що видає на виході уніфіковану DTO. Після нормалізації дані проходять через єдиний конвеєр обробки.
Модель даних
Ключове рішення — зберігати вихідні дані поставника окремо від остаточної картки товару.
-- Вихідні дані від поставників (сирові)
CREATE TABLE supplier_products (
id BIGSERIAL PRIMARY KEY,
supplier_id INT NOT NULL REFERENCES suppliers(id),
external_id VARCHAR(255) NOT NULL, -- ID у системі поставника
sku VARCHAR(255),
barcode VARCHAR(50),
name TEXT NOT NULL,
price NUMERIC(12,2),
stock INT DEFAULT 0,
attributes JSONB DEFAULT '{}',
raw_data JSONB, -- вихідний документ повністю
imported_at TIMESTAMP NOT NULL,
hash VARCHAR(64), -- SHA256 вмісту для детекції змін
UNIQUE(supplier_id, external_id)
);
-- Остаточні картки товарів
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
master_sku VARCHAR(255) UNIQUE,
name TEXT,
price NUMERIC(12,2),
stock INT,
primary_supplier_id INT REFERENCES suppliers(id),
merged_from INT[], -- supplier_product_id[]
updated_at TIMESTAMP
);
Адаптери поставників
Кожен адаптер реалізує єдиний інтерфейс:
interface SupplierAdapterInterface
{
public function fetch(): Generator; // yields SupplierProductDTO
public function getSupplierId(): int;
}
class SupplierProductDTO
{
public function __construct(
public readonly string $externalId,
public readonly string $name,
public readonly float $price,
public readonly int $stock,
public readonly ?string $sku = null,
public readonly ?string $barcode = null,
public readonly array $attributes = [],
) {}
}
Адаптер для XML-поставника:
class XmlSupplierAdapter implements SupplierAdapterInterface
{
public function __construct(
private readonly int $supplierId,
private readonly string $feedUrl,
) {}
public function fetch(): Generator
{
$reader = new XMLReader();
$reader->open($this->feedUrl);
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->name === 'item') {
$node = new SimpleXMLElement($reader->readOuterXML());
yield new SupplierProductDTO(
externalId: (string) $node->id,
name: (string) $node->name,
price: (float) $node->price,
stock: (int) $node->quantity,
sku: (string) $node->article ?: null,
barcode: (string) $node->barcode ?: null,
);
}
}
}
public function getSupplierId(): int
{
return $this->supplierId;
}
}
Адаптер для CSV:
class CsvSupplierAdapter implements SupplierAdapterInterface
{
public function fetch(): Generator
{
$handle = fopen($this->filePath, 'r');
$headers = fgetcsv($handle, 0, ';');
$headers = array_map('trim', $headers);
while (($row = fgetcsv($handle, 0, ';')) !== false) {
$data = array_combine($headers, $row);
yield new SupplierProductDTO(
externalId: $data['ID'],
name: $data['Найменування'],
price: (float) str_replace(',', '.', $data['Ціна']),
stock: (int) $data['Залишок'],
sku: $data['Артикул'] ?? null,
barcode: $data['Штрихкод'] ?? null,
);
}
fclose($handle);
}
}
Сервіс імпорту
class SupplierImportService
{
public function import(SupplierAdapterInterface $adapter): ImportResult
{
$supplierId = $adapter->getSupplierId();
$result = new ImportResult();
DB::transaction(function () use ($adapter, $supplierId, $result) {
foreach ($adapter->fetch() as $dto) {
$hash = hash('sha256', serialize($dto));
$existing = SupplierProduct::where([
'supplier_id' => $supplierId,
'external_id' => $dto->externalId,
])->first();
if ($existing && $existing->hash === $hash) {
$result->skipped++;
continue; // Дані не змінилися
}
SupplierProduct::updateOrCreate(
['supplier_id' => $supplierId, 'external_id' => $dto->externalId],
[
'name' => $dto->name,
'price' => $dto->price,
'stock' => $dto->stock,
'sku' => $dto->sku,
'barcode' => $dto->barcode,
'attributes' => $dto->attributes,
'imported_at' => now(),
'hash' => $hash,
]
);
$result->upserted++;
}
// Позначити товари, що зникли з останнього експорту
SupplierProduct::where('supplier_id', $supplierId)
->where('imported_at', '<', now()->subMinutes(30))
->update(['stock' => 0]);
});
return $result;
}
}
Черги та паралельний імпорт
Кожен поставник запускається як окремий job:
class ImportSupplierJob implements ShouldQueue
{
public int $timeout = 1800; // 30 хвилин
public int $tries = 3;
public function handle(SupplierImportService $service): void
{
$adapter = SupplierAdapterFactory::make($this->supplier);
$result = $service->import($adapter);
Log::info("Поставник {$this->supplier->name} імпортований", $result->toArray());
// Запустити мерж після імпорту всіх активних поставників
if ($this->isLastActiveImport()) {
MergeProductsJob::dispatch();
}
}
}
Запуск за розписанням:
$schedule->job(new ImportSupplierJob($supplierA))->everyTwoHours();
$schedule->job(new ImportSupplierJob($supplierB))->everyTwoHours()->delay(5);
$schedule->job(new ImportSupplierJob($supplierC))->everyFourHours();
Моніторинг та сповіщення
Важливі метрики для відстеження:
- Кількість імпортованих / пропущених / невдалих записів
- Відсоток товарів, де ціна змінилася на ±20% або більше — ймовірна помилка поставника
- Час виконання імпорту — зростання означає розширення каталогу або деградацію джерела
if ($result->priceAnomalies > $result->upserted * 0.05) {
Notification::send($admins, new SupplierPriceAnomalyAlert($supplier, $result));
}
Графік реалізації
- Базова модель даних + 2 адаптери (XML + CSV): 2 дні
- Кожен додатковий адаптер: +0.5–1 день
- Адаптер для REST API поставника: +1–2 дні
- Черги, логування, сповіщення: +1 день
- Правила мержу та пріоритизації: +1–2 дні (описано в окремій послузі)
Типовий проект з трьома поставниками та базовим мержем: 5–7 робочих днів.







