Реалізація валідації даних при імпорті товарів
Дані від постачальників — це неконтрольований вхід. У прайсі можуть бути негативні ціни, порожні артикули, невірні одиниці виміру, XSS у описах та рядки замість чисел. Без валідації це попадає прямо в каталог. Хороша система валідації затримує мусор на вході і надає чіткий звіт про проблеми.
Два рівні валідації
Структурна валідація — перевіряє формат і типи даних:
- Обов'язкові поля присутні
- Числа є числами
- Дати в правильному форматі
- Довжина рядків у межах допустимого
Бізнес-валідація — перевіряє смислові правила:
- Ціна не перевищує розумну межу
- SKU унікальний у межах джерела
- Категорія існує в системі
- Зміна ціни не перевищує допустимий відсоток
Побудова валідатора на основі Laravel Validator
class ProductImportValidator
{
private array $rules = [
'sku' => ['required', 'string', 'max:100'],
'name' => ['required', 'string', 'max:500'],
'price' => ['required', 'numeric', 'min:0.01', 'max:100000000'],
'qty' => ['nullable', 'integer', 'min:0', 'max:9999999'],
'description' => ['nullable', 'string', 'max:100000'],
'category' => ['nullable', 'string', 'max:300'],
'images' => ['nullable', 'array', 'max:20'],
'images.*' => ['url', 'max:2000'],
'weight' => ['nullable', 'numeric', 'min:0', 'max:10000'],
];
public function validate(array $row): ValidationResult
{
$validator = \Illuminate\Support\Facades\Validator::make(
$row,
$this->rules,
$this->customMessages()
);
$errors = [];
if ($validator->fails()) {
$errors = $validator->errors()->toArray();
}
// Бізнес-правила
$errors = array_merge($errors, $this->applyBusinessRules($row));
return new ValidationResult(
valid: empty($errors),
errors: $errors,
data: $validator->validated(),
);
}
private function applyBusinessRules(array $row): array
{
$errors = [];
// Перевірка аномального зміни ціни
if (!empty($row['sku']) && !empty($row['price'])) {
$existing = Product::where('sku', $row['sku'])->value('price');
if ($existing && $existing > 0) {
$change = abs($row['price'] - $existing) / $existing;
if ($change > 0.5) {
$errors['price'][] = "Price change {$change}% exceeds 50% threshold";
}
}
}
// Перевірка на XSS у описі
if (!empty($row['description'])) {
$clean = strip_tags($row['description']);
if ($clean !== $row['description']) {
$errors['description'][] = 'HTML tags detected in description';
}
}
return $errors;
}
}
Санітизація даних перед валідацією
Спочатку очищаємо очевидний мусор, потім валідуємо:
class ProductDataSanitizer
{
public function sanitize(array $raw): array
{
return [
'sku' => $this->cleanString($raw['sku'] ?? ''),
'name' => $this->cleanString($raw['name'] ?? ''),
'price' => $this->parseDecimal($raw['price'] ?? null),
'qty' => $this->parseInt($raw['qty'] ?? null),
'description' => $this->sanitizeHtml($raw['description'] ?? ''),
'weight' => $this->parseDecimal($raw['weight'] ?? null),
'images' => $this->parseImageUrls($raw['images'] ?? []),
];
}
private function cleanString(?string $value): string
{
if ($value === null) return '';
$value = trim($value);
$value = preg_replace('/\p{C}/u', '', $value); // невидимі символи
return mb_substr($value, 0, 1000);
}
private function parseDecimal(mixed $value): ?float
{
if ($value === null || $value === '') return null;
$value = str_replace([' ', ',', "\xc2\xa0"], ['', '.', ''], (string) $value);
return is_numeric($value) ? (float) $value : null;
}
private function parseInt(mixed $value): ?int
{
$decimal = $this->parseDecimal($value);
return $decimal !== null ? (int) $decimal : null;
}
private function sanitizeHtml(?string $html): string
{
if (!$html) return '';
// Дозволяємо лише безпечні теги
return strip_tags($html, '<p><br><b><strong><i><em><ul><ol><li>');
}
private function parseImageUrls(mixed $raw): array
{
if (is_string($raw)) {
$raw = array_map('trim', explode(',', $raw));
}
return array_values(array_filter(
(array) $raw,
fn($url) => filter_var($url, FILTER_VALIDATE_URL)
));
}
}
Пакетна дедуплікація SKU у межах імпорту
Один файл може містити дублюючиеся артикули (постачальник склеїв кілька прайсів):
class ImportDeduplicator
{
private array $seenSkus = [];
public function check(string $sku, int $lineNumber): ?string
{
if (isset($this->seenSkus[$sku])) {
return "Duplicate SKU '{$sku}' at line {$lineNumber}, first seen at line {$this->seenSkus[$sku]}";
}
$this->seenSkus[$sku] = $lineNumber;
return null;
}
}
Стратегії обробки помилок
Не всі помилки одинакові — потрібно розділити їх за критичністю:
enum ValidationSeverity: string
{
case CRITICAL = 'critical'; // рядок пропускається
case WARNING = 'warning'; // рядок імпортується з прапором
case INFO = 'info'; // лише в журнал
}
Правила з критичністю:
| Правило | Критичність |
|---|---|
| Порожній SKU | CRITICAL |
| Відсутня назва | CRITICAL |
| Ціна = 0 або негативна | CRITICAL |
| Ціна змінилася >50% | WARNING |
| HTML у описі | WARNING |
| Категорія не знайдена | INFO |
| Битий URL зображення | INFO |
Збір і зберігання помилок
class ImportErrorCollector
{
private array $errors = [];
private int $criticalCount = 0;
public function add(int $line, string $sku, string $field, string $message, ValidationSeverity $severity): void
{
$this->errors[] = compact('line', 'sku', 'field', 'message', 'severity');
if ($severity === ValidationSeverity::CRITICAL) {
$this->criticalCount++;
}
}
public function persist(int $importId): void
{
if (empty($this->errors)) return;
// Записуємо пакетами по 1000 рядків
foreach (array_chunk($this->errors, 1000) as $chunk) {
ImportError::insert(array_map(
fn($e) => array_merge($e, ['import_id' => $importId, 'severity' => $e['severity']->value]),
$chunk
));
}
ImportRun::find($importId)->update([
'errors_count' => count($this->errors),
'critical_errors_count' => $this->criticalCount,
]);
}
}
Поріг скасування імпорту
Якщо забагато критичних помилок — зупиняємо імпорт повністю:
private const ABORT_THRESHOLD = 0.20; // 20% критичних — стоп
if ($collector->getCriticalRate() > self::ABORT_THRESHOLD) {
throw new ImportAbortedException(
"Too many critical errors: {$collector->getCriticalRate()}%"
);
}
Терміни реалізації
- Структурна валідація (Laravel Validator), санітизація, збір помилок — 1 день
- Бізнес-правила, дедуплікація SKU, рівні severity — +1 день
- Зберігання помилок в БД, поріг скасування, відображення в admin UI — +1 день







