feat: улучшена информативность ошибок при импорте CSV

- В сообщениях об ошибках теперь отображается содержимое проблемной строки
- Добавлена специальная обработка ошибки Data truncated (обрезание данных)
- Показывается конкретное значение поля, вызвавшего ошибку
- Добавлена информация о типе и размере столбца при truncation
- Улучшено форматирование вывода ошибок (JSON для больших данных)
- Номер строки теперь соответствует номеру в исходном CSV файле (+2 для учета заголовка)
This commit is contained in:
2026-01-21 04:29:56 +03:00
parent bd96348850
commit 2189805015
2 changed files with 315 additions and 140 deletions

View File

@@ -791,13 +791,36 @@ document.getElementById('csvFileInput').addEventListener('change', async (e) =>
rows: records rows: records
}); });
if (result.errorMessages && result.errorMessages.length > 0) { if (result.errorMessages && result.errorMessages.length > 0) {
const errorsToShow = result.errorMessages.slice(0, 10); // Показываем только первые 10 ошибок
const moreErrors = result.errorMessages.length > 10 ? `\n... и еще ${result.errorMessages.length - 10} ошибок` : ''; const errorsToShow = result.errorMessages.slice(0, 10);
alert(`Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}\n\nПервые ошибки:\n${errorsToShow.join('\n')}${moreErrors}`); const moreErrors = result.errorMessages.length > 10
} else { ? `\n... и еще ${result.errorMessages.length - 10} ошибок`
alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`); : '';
}
// Создаем детальное сообщение
let errorDetails = `Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}\n\n`;
errorDetails += 'ПЕРВЫЕ ОШИБКИ:\n';
errorDetails += '═══════════════════════════════════════\n';
errorDetails += errorsToShow.join('\n\n');
errorDetails += moreErrors;
// Выводим в alert (для больших сообщений можно использовать модальное окно)
alert(errorDetails);
// Также выводим в консоль для детального анализа
console.group('📋 Детали импорта CSV');
console.log(`✅ Импортировано: ${result.inserted}`);
console.log(`❌ Ошибок: ${result.errors}`);
console.log('\nВсе ошибки:');
result.errorMessages.forEach((msg, idx) => {
console.log(`${idx + 1}. ${msg}`);
});
console.groupEnd();
} else {
alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`);
}
await table.replaceData(); await table.replaceData();
e.target.value = ''; e.target.value = '';

View File

@@ -129,38 +129,62 @@ class DataService
} }
} }
private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string
{ {
$code = $e->getCode(); $code = $e->getCode();
$message = $e->getMessage(); $message = $e->getMessage();
if ($code === '23000' && str_contains($message, 'foreign key constraint')) { // Data truncated (обрезание данных)
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) { if ($code === '01000' && str_contains($message, 'Data truncated')) {
$field = $matches[1]; if (preg_match('/column \'([^\']+)\'/', $message, $matches)) {
return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы."; $field = $matches[1];
$value = 'N/A';
// Пытаемся найти значение в параметрах
foreach ($params as $paramName => $paramValue) {
if (str_contains($paramName, $field)) {
$value = $paramValue;
break;
}
} }
return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
} if (is_numeric($value)) {
return "Ошибка: поле '{$field}' - значение '{$value}' слишком большое или имеет неверный формат.";
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
$value = $matches[1];
$key = $matches[2];
return "Ошибка: значение '{$value}' уже существует (ключ '{$key}').";
} }
return "Ошибка: запись с таким значением уже существует.";
return "Ошибка: поле '{$field}' - данные обрезаны для значения '{$value}'.";
} }
return "Ошибка: данные обрезаны при вставке.";
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
$field = $matches[1];
return "Ошибка: поле '{$field}' обязательно для заполнения.";
}
return "Ошибка: не заполнены обязательные поля.";
}
return "Ошибка БД: " . $message;
} }
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
$field = $matches[1];
return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы.";
}
return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
}
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
$value = $matches[1];
$key = $matches[2];
return "Ошибка: значение '{$value}' уже существует (ключ '{$key}').";
}
return "Ошибка: запись с таким значением уже существует.";
}
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
$field = $matches[1];
return "Ошибка: поле '{$field}' обязательно для заполнения.";
}
return "Ошибка: не заполнены обязательные поля.";
}
return "Ошибка БД: " . $message;
}
public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array
{ {
@@ -235,122 +259,250 @@ class DataService
return ['deleted' => $stmt->rowCount()]; return ['deleted' => $stmt->rowCount()];
} }
public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
{ {
error_log("=== ИМПОРТ CSV: Schema=$schema, Table=$table, Строк=" . count($rows) . " ==="); error_log("=== ИМПОРТ CSV: Schema=$schema, Table=$table, Строк=" . count($rows) . " ===");
if (empty($rows)) { if (empty($rows)) {
return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided']; return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
} }
$inserted = 0; $inserted = 0;
$errors = 0; $errors = 0;
$errorMessages = []; $errorMessages = [];
// Собираем информацию о всех столбцах таблицы // Собираем информацию о всех столбцах таблицы
$validColumns = []; $validColumns = [];
foreach ($columns as $c) { $columnTypes = []; // Для детальной информации об ошибках
$name = $c['COLUMN_NAME'];
$extra = $c['EXTRA'] ?? ''; // Предварительная проверка данных
$dataType = strtolower($c['DATA_TYPE'] ?? ''); $warnings = [];
foreach ($rows as $index => $row) {
$isNullable = ($c['IS_NULLABLE'] === true || $c['IS_NULLABLE'] === 'YES'); foreach ($row as $field => $value) {
if (isset($columnTypes[$field]) && is_numeric($value)) {
// Пропускаем только auto_increment поля $type = $columnTypes[$field]['data_type'];
if (!str_contains($extra, 'auto_increment')) {
$validColumns[$name] = [ // Проверка для INT типов
'nullable' => $isNullable, if (str_contains($type, 'int')) {
'has_default' => !empty($c['COLUMN_DEFAULT']) || $c['COLUMN_DEFAULT'] === '0', if ($value > 2147483647) { // INT max value
'default' => $c['COLUMN_DEFAULT'] ?? null, $warnings[] = "Строка CSV #" . ($index + 2) . ": поле '$field' = '$value' превышает максимальное значение для типа INT";
'data_type' => $dataType
];
}
}
$this->pdo->beginTransaction();
try {
foreach ($rows as $index => $row) {
try {
$insertCols = [];
$placeholders = [];
$params = [];
// Перебираем ВСЕ столбцы таблицы (кроме auto_increment)
foreach ($validColumns as $name => $info) {
$value = null;
// Если столбец есть в CSV строке
if (array_key_exists($name, $row)) {
$value = $row[$name];
// Обрабатываем пустые значения как NULL
if ($value === null || $value === '' || in_array($value, ['NULL', 'null'], true)) {
$value = null;
} else {
// Конвертируем форматы дат для date/datetime полей
if (in_array($info['data_type'], ['date', 'datetime', 'timestamp'])) {
$value = $this->convertDateFormat($value);
}
}
} else {
// Столбца нет в CSV
if ($info['has_default']) {
continue;
}
if ($info['nullable']) {
$value = null;
} else {
throw new \PDOException("Missing required field: $name");
}
}
$insertCols[] = "`$name`";
$placeholders[] = ":$name";
$params[":$name"] = $value;
} }
}
if (empty($insertCols)) {
continue; // Проверка для DECIMAL/FLOAT
} if (str_contains($type, 'decimal') || str_contains($type, 'float')) {
if (strlen((string)$value) > 15) {
$sql = sprintf( $warnings[] = "Строка CSV #" . ($index + 2) . ": поле '$field' = '$value' может быть слишком большим";
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
$schema, $table,
implode(',', $insertCols),
implode(',', $placeholders)
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$inserted++;
} catch (\PDOException $e) {
$errors++;
$errorMsg = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table);
$errorMessages[] = $errorMsg;
// Логируем только первые 10 ошибок
if ($errors <= 10) {
error_log("Ошибка импорта строки " . ($index + 1) . ": " . $e->getMessage());
} }
} }
} }
}
}
$this->pdo->commit(); if (!empty($warnings) && count($warnings) <= 20) {
error_log("=== ИМПОРТ ЗАВЕРШЁН: вставлено=$inserted, ошибок=$errors ==="); error_log("Предупреждения перед импортом:\n" . implode("\n", $warnings));
} catch (\Exception $e) { }
$this->pdo->rollBack();
error_log("=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА: " . $e->getMessage() . " ==="); foreach ($columns as $c) {
throw new \RuntimeException('Import failed: ' . $e->getMessage()); $name = $c['COLUMN_NAME'];
$extra = $c['EXTRA'] ?? '';
$dataType = strtolower($c['DATA_TYPE'] ?? '');
$isNullable = ($c['IS_NULLABLE'] === true || $c['IS_NULLABLE'] === 'YES');
// Сохраняем информацию о типе столбца
$columnTypes[$name] = [
'type' => $c['COLUMN_TYPE'] ?? $dataType,
'data_type' => $dataType
];
// Пропускаем только auto_increment поля
if (!str_contains($extra, 'auto_increment')) {
$validColumns[$name] = [
'nullable' => $isNullable,
'has_default' => !empty($c['COLUMN_DEFAULT']) || $c['COLUMN_DEFAULT'] === '0',
'default' => $c['COLUMN_DEFAULT'] ?? null,
'data_type' => $dataType
];
}
}
$this->pdo->beginTransaction();
try {
foreach ($rows as $index => $row) {
try {
$insertCols = [];
$placeholders = [];
$params = [];
// Перебираем ВСЕ столбцы таблицы (кроме auto_increment)
foreach ($validColumns as $name => $info) {
$value = null;
// Если столбец есть в CSV строке
if (array_key_exists($name, $row)) {
$value = $row[$name];
// Обрабатываем пустые значения как NULL
if ($value === null || $value === '' || in_array($value, ['NULL', 'null'], true)) {
$value = null;
} else {
// Конвертируем форматы дат для date/datetime полей
if (in_array($info['data_type'], ['date', 'datetime', 'timestamp'])) {
$value = $this->convertDateFormat($value);
}
}
} else {
// Столбца нет в CSV
if ($info['has_default']) {
continue;
}
if ($info['nullable']) {
$value = null;
} else {
throw new \PDOException("Missing required field: $name");
}
}
$insertCols[] = "`$name`";
$placeholders[] = ":$name";
$params[":$name"] = $value;
}
if (empty($insertCols)) {
continue;
}
$sql = sprintf(
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
$schema, $table,
implode(',', $insertCols),
implode(',', $placeholders)
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$inserted++;
} catch (\PDOException $e) {
$errors++;
// ✅ Формируем детальное сообщение об ошибке с данными строки
$csvLineNumber = $index + 2; // +2 потому что: +1 для человеческой нумерации, +1 для строки заголовка
$errorMsg = $this->formatImportError($e, $csvLineNumber, $row, $columnTypes);
$errorMessages[] = $errorMsg;
// Логируем только первые 10 ошибок
if ($errors <= 10) {
error_log("Ошибка импорта строки CSV #$csvLineNumber: " . $e->getMessage());
error_log("Данные строки: " . json_encode($row, JSON_UNESCAPED_UNICODE));
}
}
} }
return [ $this->pdo->commit();
'inserted' => $inserted, error_log("=== ИМПОРТ ЗАВЕРШЁН: вставлено=$inserted, ошибок=$errors ===");
'errors' => $errors, } catch (\Exception $e) {
'errorMessages' => $errorMessages $this->pdo->rollBack();
]; error_log("=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА: " . $e->getMessage() . " ===");
throw new \RuntimeException('Import failed: ' . $e->getMessage());
} }
return [
'inserted' => $inserted,
'errors' => $errors,
'errorMessages' => $errorMessages
];
}
/**
* Форматирует ошибку импорта с детальной информацией
*/
private function formatImportError(\PDOException $e, int $lineNumber, array $rowData, array $columnTypes): string
{
$code = $e->getCode();
$message = $e->getMessage();
$baseMsg = "Строка CSV #{$lineNumber}: ";
// Обработка Data truncated (обрезание данных)
if ($code === '01000' && str_contains($message, 'Data truncated')) {
if (preg_match('/column \'([^\']+)\'/', $message, $matches)) {
$field = $matches[1];
$value = $rowData[$field] ?? 'N/A';
$columnType = $columnTypes[$field]['type'] ?? 'unknown';
// Проверяем, не слишком ли большое число
if (is_numeric($value)) {
$valueLength = strlen((string)$value);
return $baseMsg . "Поле '{$field}' ({$columnType}): значение '{$value}' (длина: {$valueLength}) слишком большое или имеет неверный формат. " .
"Данные строки: " . $this->formatRowData($rowData);
}
return $baseMsg . "Поле '{$field}' ({$columnType}): данные обрезаны для значения '{$value}'. " .
"Данные строки: " . $this->formatRowData($rowData);
}
return $baseMsg . "Данные обрезаны. Строка: " . $this->formatRowData($rowData);
}
// Foreign Key constraint
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
$field = $matches[1];
$value = $rowData[$field] ?? 'N/A';
return $baseMsg . "Поле '{$field}' = '{$value}': значение не существует в связанной таблице. " .
"Данные: " . $this->formatRowData($rowData);
}
return $baseMsg . "Нарушена связь с другой таблицей. Данные: " . $this->formatRowData($rowData);
}
// Duplicate key
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
$value = $matches[1];
$key = $matches[2];
return $baseMsg . "Дубликат: значение '{$value}' (ключ '{$key}') уже существует. " .
"Данные: " . $this->formatRowData($rowData);
}
return $baseMsg . "Дубликат записи. Данные: " . $this->formatRowData($rowData);
}
// NOT NULL constraint
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
$field = $matches[1];
return $baseMsg . "Поле '{$field}' обязательно для заполнения. " .
"Данные: " . $this->formatRowData($rowData);
}
return $baseMsg . "Не заполнены обязательные поля. Данные: " . $this->formatRowData($rowData);
}
// Общая ошибка БД
return $baseMsg . "Ошибка БД: " . $message . ". Данные: " . $this->formatRowData($rowData);
}
/**
* Форматирует данные строки для вывода (сокращает длинные значения)
*/
private function formatRowData(array $rowData): string
{
$formatted = [];
foreach ($rowData as $key => $value) {
if ($value === null) {
$formatted[] = "$key=NULL";
} else {
// Ограничиваем длину значения для удобства чтения
$strValue = (string)$value;
if (strlen($strValue) > 50) {
$strValue = substr($strValue, 0, 50) . '...';
}
$formatted[] = "$key='$strValue'";
}
}
return implode(', ', $formatted);
}
/** /**
* Конвертирует различные форматы дат в формат MySQL (YYYY-MM-DD) * Конвертирует различные форматы дат в формат MySQL (YYYY-MM-DD)
*/ */