diff --git a/public/app.js b/public/app.js index 50c0eb8..47627c7 100644 --- a/public/app.js +++ b/public/app.js @@ -791,13 +791,36 @@ document.getElementById('csvFileInput').addEventListener('change', async (e) => rows: records }); - if (result.errorMessages && result.errorMessages.length > 0) { - const errorsToShow = result.errorMessages.slice(0, 10); - const moreErrors = result.errorMessages.length > 10 ? `\n... и еще ${result.errorMessages.length - 10} ошибок` : ''; - alert(`Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}\n\nПервые ошибки:\n${errorsToShow.join('\n')}${moreErrors}`); - } else { - alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`); - } +if (result.errorMessages && result.errorMessages.length > 0) { + // Показываем только первые 10 ошибок + const errorsToShow = result.errorMessages.slice(0, 10); + const moreErrors = result.errorMessages.length > 10 + ? `\n... и еще ${result.errorMessages.length - 10} ошибок` + : ''; + + // Создаем детальное сообщение + 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(); e.target.value = ''; diff --git a/src/DataService.php b/src/DataService.php index d5657f3..5a958c1 100644 --- a/src/DataService.php +++ b/src/DataService.php @@ -129,38 +129,62 @@ class DataService } } - private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string - { - $code = $e->getCode(); - $message = $e->getMessage(); - - if ($code === '23000' && str_contains($message, 'foreign key constraint')) { - if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) { - $field = $matches[1]; - return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы."; +private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string +{ + $code = $e->getCode(); + $message = $e->getMessage(); + + // Data truncated (обрезание данных) + if ($code === '01000' && str_contains($message, 'Data truncated')) { + if (preg_match('/column \'([^\']+)\'/', $message, $matches)) { + $field = $matches[1]; + $value = 'N/A'; + + // Пытаемся найти значение в параметрах + foreach ($params as $paramName => $paramValue) { + if (str_contains($paramName, $field)) { + $value = $paramValue; + break; + } } - 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}')."; + + if (is_numeric($value)) { + return "Ошибка: поле '{$field}' - значение '{$value}' слишком большое или имеет неверный формат."; } - return "Ошибка: запись с таким значением уже существует."; + + return "Ошибка: поле '{$field}' - данные обрезаны для значения '{$value}'."; } - - 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; + return "Ошибка: данные обрезаны при вставке."; } + + 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 { @@ -235,122 +259,250 @@ class DataService return ['deleted' => $stmt->rowCount()]; } - public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array - { - error_log("=== ИМПОРТ CSV: Schema=$schema, Table=$table, Строк=" . count($rows) . " ==="); - - if (empty($rows)) { - return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided']; - } +public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array +{ + error_log("=== ИМПОРТ CSV: Schema=$schema, Table=$table, Строк=" . count($rows) . " ==="); + + if (empty($rows)) { + return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided']; + } - $inserted = 0; - $errors = 0; - $errorMessages = []; + $inserted = 0; + $errors = 0; + $errorMessages = []; - // Собираем информацию о всех столбцах таблицы - $validColumns = []; - foreach ($columns as $c) { - $name = $c['COLUMN_NAME']; - $extra = $c['EXTRA'] ?? ''; - $dataType = strtolower($c['DATA_TYPE'] ?? ''); - - $isNullable = ($c['IS_NULLABLE'] === true || $c['IS_NULLABLE'] === 'YES'); - - // Пропускаем только 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; + // Собираем информацию о всех столбцах таблицы + $validColumns = []; + $columnTypes = []; // Для детальной информации об ошибках + + // Предварительная проверка данных + $warnings = []; + foreach ($rows as $index => $row) { + foreach ($row as $field => $value) { + if (isset($columnTypes[$field]) && is_numeric($value)) { + $type = $columnTypes[$field]['data_type']; + + // Проверка для INT типов + if (str_contains($type, 'int')) { + if ($value > 2147483647) { // INT max value + $warnings[] = "Строка CSV #" . ($index + 2) . ": поле '$field' = '$value' превышает максимальное значение для типа INT"; } - - 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++; - $errorMsg = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table); - $errorMessages[] = $errorMsg; - - // Логируем только первые 10 ошибок - if ($errors <= 10) { - error_log("Ошибка импорта строки " . ($index + 1) . ": " . $e->getMessage()); + } + + // Проверка для DECIMAL/FLOAT + if (str_contains($type, 'decimal') || str_contains($type, 'float')) { + if (strlen((string)$value) > 15) { + $warnings[] = "Строка CSV #" . ($index + 2) . ": поле '$field' = '$value' может быть слишком большим"; } } } + } + } - $this->pdo->commit(); - error_log("=== ИМПОРТ ЗАВЕРШЁН: вставлено=$inserted, ошибок=$errors ==="); - } catch (\Exception $e) { - $this->pdo->rollBack(); - error_log("=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА: " . $e->getMessage() . " ==="); - throw new \RuntimeException('Import failed: ' . $e->getMessage()); + if (!empty($warnings) && count($warnings) <= 20) { + error_log("Предупреждения перед импортом:\n" . implode("\n", $warnings)); + } + + foreach ($columns as $c) { + $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 [ - 'inserted' => $inserted, - 'errors' => $errors, - 'errorMessages' => $errorMessages - ]; + $this->pdo->commit(); + error_log("=== ИМПОРТ ЗАВЕРШЁН: вставлено=$inserted, ошибок=$errors ==="); + } catch (\Exception $e) { + $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) */