Реализация валидации данных при импорте товаров
Данные от поставщиков — это не контролируемый вход. В прайсе могут быть отрицательные цены, пустые артикулы, неверные единицы измерения, 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 день







