From fa0fb536fdaacd955cbda023082e7b78d1c1d7ee Mon Sep 17 00:00:00 2001 From: Aleksey Filippov Date: Tue, 17 Feb 2026 00:34:45 +0300 Subject: [PATCH] =?utf8?q?[erp-233]=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?utf8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE?= =?utf8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=B7=D0=B0?= =?utf8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D1=83=20=D1=84=D0=B0=D0=B9=D0=BB?= =?utf8?q?=D0=BE=D0=B2=20=D0=B2=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?utf8?q?=D0=BD=D1=82=20=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- docker/php/dev.php.env | 5 + erp24/config/params.php | 7 + erp24/config/web.php | 4 +- erp24/controllers/WriteOffsErpController.php | 344 +++++++++- ...200000_create_upload_speed_tests_table.php | 61 ++ erp24/records/UploadSpeedTest.php | 92 +++ erp24/services/UploadService.php | 2 +- .../WriteOffsErpSpeedTestConfigTest.php | 606 ++++++++++++++++++ .../unit/records/UploadSpeedTestTest.php | 565 ++++++++++++++++ erp24/views/write_offs_erp/create.php | 19 +- erp24/views/write_offs_erp/update.php | 4 +- erp24/web/js/heic_to_jpg_replace.js | 73 ++- erp24/web/js/upload-speed-test.js | 183 ++++++ erp24/web/js/validate/validateForm.js | 252 +++++++- erp24/web/js/write-offs-erp/update.js | 27 +- erp24/web/speed-test.html | 32 + 16 files changed, 2222 insertions(+), 54 deletions(-) create mode 100644 erp24/migrations/m260215_200000_create_upload_speed_tests_table.php create mode 100644 erp24/records/UploadSpeedTest.php create mode 100644 erp24/tests/unit/controllers/WriteOffsErpSpeedTestConfigTest.php create mode 100644 erp24/tests/unit/records/UploadSpeedTestTest.php create mode 100644 erp24/web/js/upload-speed-test.js create mode 100644 erp24/web/speed-test.html diff --git a/docker/php/dev.php.env b/docker/php/dev.php.env index d45239a9..54d115cd 100644 --- a/docker/php/dev.php.env +++ b/docker/php/dev.php.env @@ -1,3 +1,8 @@ # PHP Container Environment APP_ENV=development TZ=Europe/Moscow + +# RabbitMQ Configuration +RABBIT_HOST=rabbitmq-yii_erp24 +RABBIT_USER=admin +RABBIT_PASSWORD=dev_rabbit_password diff --git a/erp24/config/params.php b/erp24/config/params.php index e80cf20e..bd903337 100644 --- a/erp24/config/params.php +++ b/erp24/config/params.php @@ -58,4 +58,11 @@ return [ // ID групп администраторов, которым показываются напоминания // Пустой массив = все пользователи с записями в timetable 'SHIFT_REMINDER_ADMIN_GROUP_IDS' => [], + + // Тест скорости загрузки — отчёт в Telegram и email + 'UPLOAD_SPEED_TEST' => [ + 'telegram_chat_id' => getenv('SPEED_TEST_TELEGRAM_CHAT_ID') ?: '', + 'email_recipients' => array_filter(explode(',', getenv('SPEED_TEST_EMAIL_RECIPIENTS') ?: '')), + 'email_subject' => '[ERP24] Тест скорости загрузки файлов', + ], ]; diff --git a/erp24/config/web.php b/erp24/config/web.php index 2353269b..72610055 100644 --- a/erp24/config/web.php +++ b/erp24/config/web.php @@ -87,15 +87,17 @@ $config = [ ], ], 'log' => [ - 'traceLevel' => 3, + 'traceLevel' => YII_DEBUG ? 3 : 0, 'targets' => [ [ 'class' => 'yii\log\FileTarget', 'levels' => ['error', 'warning'], + 'logVars' => [], ], [ 'class' => 'app\log\TelegramTarget', 'levels' => ['error', 'warning'], + 'logVars' => [], // 'categories' => ['api.error', 'js.error', 'command.error'], ], ], diff --git a/erp24/controllers/WriteOffsErpController.php b/erp24/controllers/WriteOffsErpController.php index 0e3dd672..836a2893 100644 --- a/erp24/controllers/WriteOffsErpController.php +++ b/erp24/controllers/WriteOffsErpController.php @@ -29,6 +29,7 @@ use yii\web\NotFoundHttpException; use yii\filters\VerbFilter; use yii_app\records\WriteOffsProductsErp; use yii_app\services\FileService; +use yii_app\records\UploadSpeedTest; use yii_app\services\TimetableService; /** @@ -115,6 +116,338 @@ class WriteOffsErpController extends Controller return $processedAnalytics; } + /** + * Отключает CSRF-валидацию для speed-test экшенов (API-стиль, JSON). + * + * @param \yii\base\Action $action + * @return bool + */ + public function beforeAction($action) + { + if (in_array($action->id, ['speed-test', 'speed-test-report'], true)) { + $this->enableCsrfValidation = false; + } + return parent::beforeAction($action); + } + + /** + * Принимает POST с тестовым blob-файлом, не сохраняет, возвращает JSON с размером. + * + * @return array + */ + public function actionSpeedTest() + { + Yii::$app->response->format = Response::FORMAT_JSON; + + if (!Yii::$app->request->isPost) { + Yii::$app->response->statusCode = 405; + return ['success' => false, 'error' => 'POST only']; + } + + $contentLength = (int)Yii::$app->request->headers->get('Content-Length', 0); + + return [ + 'success' => true, + 'received_bytes' => $contentLength, + ]; + } + + /** + * Принимает JSON с результатом теста скорости, отправляет отчёт в Telegram и на email. + * Данные: кто (admin), когда, откуда (IP, User-Agent), скорость, заключение. + * + * @return array + */ + public function actionSpeedTestReport() + { + Yii::$app->response->format = Response::FORMAT_JSON; + + if (!Yii::$app->request->isPost) { + Yii::$app->response->statusCode = 405; + return ['success' => false, 'error' => 'POST only']; + } + + $json = json_decode(Yii::$app->request->rawBody, true); + if (empty($json)) { + Yii::$app->response->statusCode = 400; + return ['success' => false, 'error' => 'Empty body']; + } + + $session = Yii::$app->session; + $adminId = (int)$session->get('admin_id'); + $adminName = $session->get('admin_name', 'Неизвестно'); + + $ip = Yii::$app->request->userIP; + $forwardedFor = Yii::$app->request->headers->get('X-Forwarded-For', ''); + $userAgent = $json['user_agent'] ?? Yii::$app->request->userAgent; + $screen = $json['screen'] ?? ''; + $pageUrl = $json['page_url'] ?? ''; + + $speedMbps = (float)($json['speed_mbps'] ?? 0); + $elapsedSec = (float)($json['elapsed_sec'] ?? 0); + $testSizeMb = (float)($json['test_size_mb'] ?? 0); + $verdict = $json['verdict'] ?? ''; + $dateTime = date('d.m.Y H:i:s'); + + $reportText = "Тест скорости загрузки файлов\n" + . "Дата: {$dateTime}\n" + . "Пользователь: {$adminName} (ID: {$adminId})\n" + . "IP: {$ip}" . ($forwardedFor ? " (X-Forwarded-For: {$forwardedFor})" : '') . "\n" + . "Устройство: {$userAgent}\n" + . "Экран: {$screen}\n" + . "Страница: {$pageUrl}\n" + . "---\n" + . "Скорость: {$speedMbps} МБ/с\n" + . "Тестовый файл: {$testSizeMb} МБ за {$elapsedSec} сек\n" + . "Заключение: {$verdict}\n"; + + $sent = ['telegram' => false, 'email' => false]; + + $params = Yii::$app->params['UPLOAD_SPEED_TEST'] ?? []; + + // Telegram + $chatId = $params['telegram_chat_id'] ?? ''; + $botToken = getenv('TELEGRAM_BOT_TOKEN'); + if ($chatId && $botToken) { + $sent['telegram'] = $this->sendTelegramReport($botToken, $chatId, $reportText); + } + + // Email + $recipients = $params['email_recipients'] ?? []; + $subject = $params['email_subject'] ?? '[ERP24] Тест скорости загрузки'; + if (!empty($recipients)) { + $sent['email'] = $this->sendEmailReport($recipients, $subject, $reportText); + } + + // Сохранение результата в таблицу + $saved = false; + try { + $record = new UploadSpeedTest(); + $record->admin_id = $adminId ?: null; + $record->admin_name = $adminName; + $record->ip = $ip; + $record->forwarded_for = $forwardedFor ?: null; + $record->user_agent = $userAgent; + $record->screen = $screen ?: null; + $record->page_url = $pageUrl ?: null; + $record->speed_mbps = $speedMbps; + $record->elapsed_sec = $elapsedSec; + $record->test_size_mb = $testSizeMb; + $record->verdict = $verdict; + $record->telegram_sent = $sent['telegram']; + $record->email_sent = $sent['email']; + $saved = $record->save(); + if (!$saved) { + Yii::error('SpeedTest DB save errors: ' . json_encode($record->getErrors(), JSON_UNESCAPED_UNICODE), 'speed-test'); + } + } catch (\Exception $e) { + Yii::error('SpeedTest DB save exception: ' . $e->getMessage(), 'speed-test'); + } + + $savedStr = $saved ? 'true' : 'false'; + Yii::info("SpeedTestReport: {$verdict}, {$speedMbps} MB/s, admin={$adminId}, ip={$ip}, saved={$savedStr}", 'speed-test'); + + return [ + 'success' => true, + 'sent' => $sent, + 'saved' => (bool)$saved, + ]; + } + + /** + * Отправляет сообщение в Telegram через Bot API. + */ + private function sendTelegramReport(string $botToken, string $chatId, string $text): bool + { + $url = "https://api.telegram.org/bot{$botToken}/sendMessage"; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => [ + 'chat_id' => $chatId, + 'text' => $text, + 'disable_web_page_preview' => true, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + Yii::error("SpeedTest Telegram error: HTTP {$httpCode}", 'speed-test'); + return false; + } + return true; + } + + /** + * Отправляет email с результатом теста скорости. + */ + private function sendEmailReport(array $recipients, string $subject, string $text): bool + { + try { + $from = Yii::$app->params['adminEmail'] ?? 'noreply@erp-flowers.ru'; + $message = Yii::$app->mailer->compose() + ->setFrom($from) + ->setTo($recipients) + ->setSubject($subject) + ->setTextBody($text); + + return $message->send(); + } catch (\Exception $e) { + Yii::error("SpeedTest email error: " . $e->getMessage(), 'speed-test'); + return false; + } + } + + /** + * Логирует данные формы при отправке (create/update). + * Записывает: поля формы, продукты, метаданные файлов (имя, размер), ошибки. + * + * @param string $action 'create' или 'update' + * @param int|null $documentId ID документа (для update) + */ + private function logFormSubmission(string $action, ?int $documentId = null): void + { + $session = Yii::$app->session; + $adminId = (int)$session->get('admin_id'); + $adminName = $session->get('admin_name', 'Неизвестно'); + $ip = Yii::$app->request->userIP; + + $postData = Yii::$app->request->post('WriteOffsErp', []); + + // Основные поля формы (без modelsProducts) + $formFields = []; + foreach ($postData as $key => $value) { + if ($key === 'modelsProducts') { + continue; + } + $formFields[$key] = $value; + } + + // Продукты (строки таблицы) + $products = []; + $modelsProductsData = $postData['modelsProducts'] ?? []; + foreach ($modelsProductsData as $idx => $row) { + $productInfo = []; + foreach ($row as $field => $val) { + if ($val !== '' && $val !== null) { + $productInfo[$field] = $val; + } + } + if (!empty($productInfo)) { + $products[$idx] = $productInfo; + } + } + + // Метаданные файлов (имя + размер, без содержимого) + $filesInfo = []; + $uploadedFiles = $_FILES ?? []; + foreach ($uploadedFiles as $inputName => $fileData) { + if (is_array($fileData['name'])) { + $this->collectFilesMeta($filesInfo, $inputName, $fileData); + } else { + if (!empty($fileData['name'])) { + $filesInfo[$inputName] = [ + 'name' => $fileData['name'], + 'size' => $fileData['size'], + 'type' => $fileData['type'], + 'error' => $fileData['error'], + ]; + } + } + } + + $logData = [ + 'action' => $action, + 'document_id' => $documentId, + 'admin_id' => $adminId, + 'admin_name' => $adminName, + 'ip' => $ip, + 'datetime' => date('Y-m-d H:i:s'), + 'user_agent' => Yii::$app->request->userAgent, + 'form_fields' => $formFields, + 'products_count' => count($products), + 'products' => $products, + 'files' => $filesInfo, + ]; + + Yii::info( + "WriteOffs form submit [{$action}]: " . json_encode($logData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + 'write-offs-erp' + ); + } + + /** + * Рекурсивно собирает метаданные файлов из $_FILES. + */ + private function collectFilesMeta(array &$result, string $prefix, array $fileData): void + { + if (is_array($fileData['name'])) { + foreach ($fileData['name'] as $key => $name) { + $subData = [ + 'name' => $fileData['name'][$key], + 'size' => $fileData['size'][$key], + 'type' => $fileData['type'][$key], + 'error' => $fileData['error'][$key], + ]; + if (is_array($name)) { + $this->collectFilesMeta($result, "{$prefix}[{$key}]", $subData); + } else { + if (!empty($name)) { + $errorText = $this->getUploadErrorText($subData['error']); + $result["{$prefix}[{$key}]"] = [ + 'name' => $name, + 'size_bytes' => $subData['size'], + 'size_human' => $this->formatFileSize($subData['size']), + 'type' => $subData['type'], + 'error_code' => $subData['error'], + 'error_text' => $errorText, + ]; + } + } + } + } + } + + /** + * Форматирует размер файла в читаемый вид. + */ + private function formatFileSize(int $bytes): string + { + if ($bytes >= 1048576) { + return round($bytes / 1048576, 1) . ' МБ'; + } + if ($bytes >= 1024) { + return round($bytes / 1024, 1) . ' КБ'; + } + return $bytes . ' Б'; + } + + /** + * Возвращает текстовое описание ошибки загрузки PHP. + */ + private function getUploadErrorText(int $errorCode): string + { + $errors = [ + UPLOAD_ERR_OK => 'OK', + UPLOAD_ERR_INI_SIZE => 'Файл превышает upload_max_filesize в php.ini', + UPLOAD_ERR_FORM_SIZE => 'Файл превышает MAX_FILE_SIZE в форме', + UPLOAD_ERR_PARTIAL => 'Файл загружен частично', + UPLOAD_ERR_NO_FILE => 'Файл не загружен', + UPLOAD_ERR_NO_TMP_DIR => 'Нет временной папки', + UPLOAD_ERR_CANT_WRITE => 'Не удалось записать файл на диск', + UPLOAD_ERR_EXTENSION => 'Загрузка остановлена расширением PHP', + ]; + return $errors[$errorCode] ?? "Неизвестная ошибка ({$errorCode})"; + } + /** * Lists all WriteOffsErp models. * @@ -457,6 +790,8 @@ class WriteOffsErpController extends Controller $processedAnalytics = $this->getProcessedAnalytics(); if ($this->request->isPost) { + $this->logFormSubmission('create'); + if ($model->load(Yii::$app->request->post())) { $modelsProducts = MultipleModel::createMultipleModel(WriteOffsProductsErp::classname(), 'WriteOffsErp', 'modelsProducts'); @@ -652,6 +987,8 @@ class WriteOffsErpController extends Controller $transaction->rollBack(); $errors = $e->getMessage(); + Yii::error("WriteOffs create error: {$errors}, admin=" . (int)$session->get('admin_id') . ", ip=" . Yii::$app->request->userIP, 'write-offs-erp'); + return $this->render('/write_offs_erp/create', [ 'model' => $model, 'multipleUploadForm' => $multipleUploadForm, @@ -797,6 +1134,8 @@ class WriteOffsErpController extends Controller if ($this->request->isPost && $model->load($this->request->post())) { + $this->logFormSubmission('update', (int)$id); + $isTransfer = (bool)Yii::$app->request->post('do_transfer', 0); $postModels = Yii::$app->request->post('WriteOffsErp')['modelsProducts'] ?? []; $transferIds = []; @@ -1047,6 +1386,9 @@ class WriteOffsErpController extends Controller $transaction->rollBack(); } + $errors = $e->getMessage(); + Yii::error("WriteOffs update error: {$errors}, document_id={$id}, admin=" . (int)$session->get('admin_id') . ", ip=" . Yii::$app->request->userIP, 'write-offs-erp'); + return $this->render('/write_offs_erp/update', [ 'model' => $model, 'multipleUploadForm' => $multipleUploadForm, @@ -1056,7 +1398,7 @@ class WriteOffsErpController extends Controller 'listProductsDict' => $listProductsDict, 'balanceDict' => $balanceDict, 'processedAnalytics' => $processedAnalytics, - 'errors' => $e->getMessage() + 'errors' => $errors ]); } } diff --git a/erp24/migrations/m260215_200000_create_upload_speed_tests_table.php b/erp24/migrations/m260215_200000_create_upload_speed_tests_table.php new file mode 100644 index 00000000..dc97542d --- /dev/null +++ b/erp24/migrations/m260215_200000_create_upload_speed_tests_table.php @@ -0,0 +1,61 @@ +createTable(self::TABLE_NAME, [ + 'id' => $this->primaryKey(), + 'admin_id' => $this->integer()->null()->comment('ID администратора (null если не авторизован)'), + 'admin_name' => $this->string(255)->null()->comment('Имя администратора'), + 'ip' => $this->string(45)->notNull()->comment('IP-адрес клиента'), + 'forwarded_for' => $this->string(255)->null()->comment('X-Forwarded-For заголовок'), + 'user_agent' => $this->text()->null()->comment('User-Agent браузера'), + 'screen' => $this->string(20)->null()->comment('Разрешение экрана (напр. 1920x1080)'), + 'page_url' => $this->string(500)->null()->comment('URL страницы, откуда запущен тест'), + 'speed_mbps' => $this->decimal(10, 2)->notNull()->comment('Скорость загрузки в МБ/с'), + 'elapsed_sec' => $this->decimal(10, 2)->notNull()->comment('Время загрузки тестового файла в секундах'), + 'test_size_mb' => $this->decimal(10, 2)->notNull()->comment('Размер тестового файла в МБ'), + 'verdict' => $this->string(50)->notNull()->comment('Заключение: Быстро, Нормально, Медленно, Очень медленно'), + 'telegram_sent' => $this->boolean()->notNull()->defaultValue(false)->comment('Отправлено ли в Telegram'), + 'email_sent' => $this->boolean()->notNull()->defaultValue(false)->comment('Отправлено ли на email'), + 'created_at' => $this->timestamp()->notNull()->defaultExpression('CURRENT_TIMESTAMP')->comment('Дата и время теста'), + ]); + + $this->createIndex( + 'idx-upload_speed_tests-admin_id', + self::TABLE_NAME, + 'admin_id' + ); + + $this->createIndex( + 'idx-upload_speed_tests-created_at', + self::TABLE_NAME, + 'created_at' + ); + + $this->createIndex( + 'idx-upload_speed_tests-ip', + self::TABLE_NAME, + 'ip' + ); + + $this->createIndex( + 'idx-upload_speed_tests-verdict', + self::TABLE_NAME, + 'verdict' + ); + } + + public function safeDown() + { + $this->dropTable(self::TABLE_NAME); + } +} diff --git a/erp24/records/UploadSpeedTest.php b/erp24/records/UploadSpeedTest.php new file mode 100644 index 00000000..9fef6701 --- /dev/null +++ b/erp24/records/UploadSpeedTest.php @@ -0,0 +1,92 @@ + 0, 'max' => 10000], + [['elapsed_sec'], 'number', 'min' => 0, 'max' => 86400], + [['test_size_mb'], 'number', 'min' => 0.01, 'max' => 1000], + [['telegram_sent', 'email_sent'], 'boolean'], + [['user_agent'], 'string'], + [['created_at'], 'safe'], + [['ip'], 'string', 'max' => 45], + [['forwarded_for'], 'string', 'max' => 255], + [['admin_name'], 'string', 'max' => 255], + [['screen'], 'string', 'max' => 20], + [['page_url'], 'string', 'max' => 500], + [['verdict'], 'string', 'max' => 50], + [['verdict'], 'in', 'range' => ['Быстро', 'Нормально', 'Медленно', 'Очень медленно']], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels() + { + return [ + 'id' => 'ID', + 'admin_id' => 'ID администратора', + 'admin_name' => 'Имя администратора', + 'ip' => 'IP-адрес', + 'forwarded_for' => 'X-Forwarded-For', + 'user_agent' => 'User-Agent', + 'screen' => 'Разрешение экрана', + 'page_url' => 'URL страницы', + 'speed_mbps' => 'Скорость (МБ/с)', + 'elapsed_sec' => 'Время (сек)', + 'test_size_mb' => 'Размер теста (МБ)', + 'verdict' => 'Заключение', + 'telegram_sent' => 'Telegram отправлен', + 'email_sent' => 'Email отправлен', + 'created_at' => 'Дата теста', + ]; + } + + /** + * Связь с администратором. + */ + public function getAdmin() + { + return $this->hasOne(Admin::class, ['id' => 'admin_id']); + } +} diff --git a/erp24/services/UploadService.php b/erp24/services/UploadService.php index f125c86b..40614fca 100644 --- a/erp24/services/UploadService.php +++ b/erp24/services/UploadService.php @@ -1780,7 +1780,7 @@ class UploadService { $sentAt = strtotime($order->sent_1c_at ?? '2025-07-01 00:00:00'); $attempts = (int)$order->attempts_number; if (($now - $sentAt) > 300) { - if ($attempts < 4) { + if ($attempts < 10) { $order->sent_1c_at = null; $order->status_1c = MarketplaceOrders::STATUSES_1C_CREATED_IN_ERP; $order->save(); diff --git a/erp24/tests/unit/controllers/WriteOffsErpSpeedTestConfigTest.php b/erp24/tests/unit/controllers/WriteOffsErpSpeedTestConfigTest.php new file mode 100644 index 00000000..815a92f2 --- /dev/null +++ b/erp24/tests/unit/controllers/WriteOffsErpSpeedTestConfigTest.php @@ -0,0 +1,606 @@ +controllerPath = $basePath . '/controllers/WriteOffsErpController.php'; + $this->webConfigPath = $basePath . '/config/web.php'; + $this->paramsPath = $basePath . '/config/params.php'; + $this->jsPath = $basePath . '/web/js/upload-speed-test.js'; + $this->createViewPath = $basePath . '/views/write_offs_erp/create.php'; + $this->migrationPath = $basePath . '/migrations/m260215_200000_create_upload_speed_tests_table.php'; + $this->modelPath = $basePath . '/records/UploadSpeedTest.php'; + } + + // ======================================================================== + // Файлы существуют + // ======================================================================== + + public function testAllRequiredFilesExist(): void + { + $files = [ + 'controller' => $this->controllerPath, + 'webConfig' => $this->webConfigPath, + 'params' => $this->paramsPath, + 'js' => $this->jsPath, + 'createView' => $this->createViewPath, + 'migration' => $this->migrationPath, + 'model' => $this->modelPath, + ]; + + foreach ($files as $name => $path) { + $this->assertFileExists($path, "Файл {$name} должен существовать: {$path}"); + } + } + + // ======================================================================== + // Controller — методы + // ======================================================================== + + public function testControllerHasActionSpeedTest(): void + { + $content = file_get_contents($this->controllerPath); + $this->assertStringContainsString( + 'public function actionSpeedTest()', + $content, + 'Controller должен содержать actionSpeedTest()' + ); + } + + public function testControllerHasActionSpeedTestReport(): void + { + $content = file_get_contents($this->controllerPath); + $this->assertStringContainsString( + 'public function actionSpeedTestReport()', + $content, + 'Controller должен содержать actionSpeedTestReport()' + ); + } + + public function testControllerReturnsJsonFormat(): void + { + $content = file_get_contents($this->controllerPath); + $this->assertStringContainsString( + 'Response::FORMAT_JSON', + $content, + 'Controller должен возвращать JSON формат' + ); + } + + // ======================================================================== + // Controller — CSRF + // ======================================================================== + + public function testControllerDisablesCsrfForSpeedTest(): void + { + $content = file_get_contents($this->controllerPath); + + $this->assertStringContainsString( + "'speed-test'", + $content, + 'beforeAction должен содержать speed-test' + ); + + $this->assertStringContainsString( + "'speed-test-report'", + $content, + 'beforeAction должен содержать speed-test-report' + ); + + $this->assertStringContainsString( + '$this->enableCsrfValidation = false', + $content, + 'CSRF должен быть отключён для speed-test экшенов' + ); + } + + // ======================================================================== + // Controller — сохранение в БД + // ======================================================================== + + public function testControllerUsesUploadSpeedTestModel(): void + { + $content = file_get_contents($this->controllerPath); + + $this->assertStringContainsString( + 'use yii_app\records\UploadSpeedTest', + $content, + 'Controller должен импортировать UploadSpeedTest' + ); + + $this->assertStringContainsString( + 'new UploadSpeedTest()', + $content, + 'Controller должен создавать UploadSpeedTest для сохранения' + ); + } + + public function testControllerSavesRecordToDb(): void + { + $content = file_get_contents($this->controllerPath); + + $this->assertStringContainsString( + '$record->save()', + $content, + 'Controller должен сохранять запись в БД' + ); + } + + public function testControllerReturnsSavedField(): void + { + $content = file_get_contents($this->controllerPath); + + $this->assertStringContainsString( + "'saved'", + $content, + 'Ответ должен содержать поле saved' + ); + } + + // ======================================================================== + // Controller — Telegram и Email + // ======================================================================== + + public function testControllerHasTelegramSendMethod(): void + { + $content = file_get_contents($this->controllerPath); + $this->assertStringContainsString( + 'private function sendTelegramReport(', + $content, + 'Controller должен иметь метод sendTelegramReport' + ); + } + + public function testControllerHasEmailSendMethod(): void + { + $content = file_get_contents($this->controllerPath); + $this->assertStringContainsString( + 'private function sendEmailReport(', + $content, + 'Controller должен иметь метод sendEmailReport' + ); + } + + public function testControllerNoHardcodedBotToken(): void + { + $content = file_get_contents($this->controllerPath); + + $this->assertDoesNotMatchRegularExpression( + '/\d{9,}:[A-Za-z0-9_-]{35}/', + $content, + 'Controller не должен содержать хардкод Telegram токенов' + ); + } + + public function testControllerUsesGetenvForToken(): void + { + $content = file_get_contents($this->controllerPath); + + $this->assertStringContainsString( + "getenv('TELEGRAM_BOT_TOKEN')", + $content, + 'Controller должен получать TELEGRAM_BOT_TOKEN из getenv' + ); + } + + // ======================================================================== + // Controller — безопасность + // ======================================================================== + + public function testControllerValidatesPostMethod(): void + { + $content = file_get_contents($this->controllerPath); + + // actionSpeedTest и actionSpeedTestReport должны проверять POST + $postCheckCount = substr_count($content, 'Yii::$app->request->isPost'); + $this->assertGreaterThanOrEqual(2, $postCheckCount, 'Должно быть минимум 2 проверки isPost (для speed-test и speed-test-report)'); + } + + public function testControllerCollectsClientMetadata(): void + { + $content = file_get_contents($this->controllerPath); + + $expectedMetadata = [ + 'Yii::$app->request->userIP', + 'X-Forwarded-For', + 'user_agent', + 'screen', + 'page_url', + ]; + + foreach ($expectedMetadata as $meta) { + $this->assertStringContainsString( + $meta, + $content, + "Controller должен собирать метаданные: {$meta}" + ); + } + } + + // ======================================================================== + // AccessControl (web.php) + // ======================================================================== + + public function testWebConfigDoesNotAllowSpeedTestWithoutAuth(): void + { + $content = file_get_contents($this->webConfigPath); + + // Ищем строку с actions для анонимного доступа + // speed-test НЕ должен быть в списке (#1: endpoints требуют авторизацию) + preg_match("/actions'\s*=>\s*\[([^\]]+)\]/", $content, $matches); + $actionsLine = $matches[1] ?? ''; + + $this->assertStringNotContainsString( + 'speed-test', + $actionsLine, + 'web.php НЕ должен разрешать speed-test без авторизации (#1)' + ); + } + + // ======================================================================== + // Params (params.php) + // ======================================================================== + + public function testParamsContainsUploadSpeedTestConfig(): void + { + $content = file_get_contents($this->paramsPath); + + $this->assertStringContainsString( + "'UPLOAD_SPEED_TEST'", + $content, + 'params.php должен содержать секцию UPLOAD_SPEED_TEST' + ); + + $this->assertStringContainsString( + "'telegram_chat_id'", + $content, + 'UPLOAD_SPEED_TEST должен содержать telegram_chat_id' + ); + + $this->assertStringContainsString( + "'email_recipients'", + $content, + 'UPLOAD_SPEED_TEST должен содержать email_recipients' + ); + } + + public function testParamsUsesEnvForChatId(): void + { + $content = file_get_contents($this->paramsPath); + + $this->assertStringContainsString( + "getenv('SPEED_TEST_TELEGRAM_CHAT_ID')", + $content, + 'telegram_chat_id должен использовать ENV переменную' + ); + } + + // ======================================================================== + // JS файл + // ======================================================================== + + public function testJsFileContainsSpeedTestLogic(): void + { + $content = file_get_contents($this->jsPath); + + $this->assertStringContainsString('TEST_SIZE_MB', $content, 'JS должен содержать TEST_SIZE_MB'); + $this->assertStringContainsString('runSpeedTest', $content, 'JS должен содержать runSpeedTest'); + $this->assertStringContainsString('sendReport', $content, 'JS должен содержать sendReport'); + $this->assertStringContainsString('getVerdict', $content, 'JS должен содержать getVerdict'); + } + + public function testJsFileUsesCorrectEndpoints(): void + { + $content = file_get_contents($this->jsPath); + + $this->assertStringContainsString( + '/write-offs-erp/speed-test', + $content, + 'JS должен использовать правильный URL для speed-test' + ); + + $this->assertStringContainsString( + '/write-offs-erp/speed-test-report', + $content, + 'JS должен использовать правильный URL для speed-test-report' + ); + } + + public function testJsFileHasVerdictThresholds(): void + { + $content = file_get_contents($this->jsPath); + + $this->assertStringContainsString('fast:', $content, 'JS должен содержать порог fast'); + $this->assertStringContainsString('normal:', $content, 'JS должен содержать порог normal'); + $this->assertStringContainsString('slow:', $content, 'JS должен содержать порог slow'); + } + + public function testJsFileHasProgressBar(): void + { + $content = file_get_contents($this->jsPath); + + $this->assertStringContainsString('speed-test-progress', $content); + $this->assertStringContainsString('speed-test-bar', $content); + $this->assertStringContainsString('upload.addEventListener', $content, 'JS должен отслеживать прогресс загрузки'); + } + + public function testJsFileHasErrorHandling(): void + { + $content = file_get_contents($this->jsPath); + + $this->assertStringContainsString("'error'", $content, 'JS должен обрабатывать ошибки'); + $this->assertStringContainsString("'timeout'", $content, 'JS должен обрабатывать таймаут'); + } + + // ======================================================================== + // View (create.php) + // ======================================================================== + + public function testCreateViewIncludesJsFile(): void + { + $content = file_get_contents($this->createViewPath); + + $this->assertStringContainsString( + 'upload-speed-test.js', + $content, + 'create.php должен подключать upload-speed-test.js' + ); + } + + public function testCreateViewHasSpeedTestButton(): void + { + $content = file_get_contents($this->createViewPath); + + $this->assertStringContainsString( + 'btn-speed-test', + $content, + 'create.php должен содержать кнопку с id=btn-speed-test' + ); + } + + public function testCreateViewHasProgressElements(): void + { + $content = file_get_contents($this->createViewPath); + + $this->assertStringContainsString('speed-test-progress', $content, 'Должен быть контейнер прогресса'); + $this->assertStringContainsString('speed-test-bar', $content, 'Должен быть progress bar'); + $this->assertStringContainsString('speed-test-result', $content, 'Должен быть контейнер результата'); + } + + // ======================================================================== + // Migration + // ======================================================================== + + public function testMigrationCreatesCorrectTable(): void + { + $content = file_get_contents($this->migrationPath); + + $this->assertStringContainsString( + 'upload_speed_tests', + $content, + 'Миграция должна создавать таблицу upload_speed_tests' + ); + + $expectedColumns = [ + 'admin_id', 'admin_name', 'ip', 'forwarded_for', + 'user_agent', 'screen', 'page_url', 'speed_mbps', + 'elapsed_sec', 'test_size_mb', 'verdict', + 'telegram_sent', 'email_sent', 'created_at', + ]; + + foreach ($expectedColumns as $column) { + $this->assertStringContainsString( + "'{$column}'", + $content, + "Миграция должна содержать столбец {$column}" + ); + } + } + + public function testMigrationHasIndexes(): void + { + $content = file_get_contents($this->migrationPath); + + $expectedIndexes = [ + 'idx-upload_speed_tests-admin_id', + 'idx-upload_speed_tests-created_at', + 'idx-upload_speed_tests-ip', + 'idx-upload_speed_tests-verdict', + ]; + + foreach ($expectedIndexes as $index) { + $this->assertStringContainsString( + $index, + $content, + "Миграция должна создавать индекс {$index}" + ); + } + } + + public function testMigrationHasDownMethod(): void + { + $content = file_get_contents($this->migrationPath); + + $this->assertStringContainsString( + 'function safeDown()', + $content, + 'Миграция должна иметь safeDown()' + ); + + $this->assertStringContainsString( + 'dropTable', + $content, + 'safeDown() должен удалять таблицу' + ); + } + + // ======================================================================== + // Исправления code review + // ======================================================================== + + /** + * #3: Telegram-логи не должны содержать полный response (утечка токена) + */ + public function testControllerDoesNotLogFullTelegramResponse(): void + { + $content = file_get_contents($this->controllerPath); + + // Не должно быть логирования полного $response от Telegram + $this->assertStringNotContainsString( + 'response: {$response}', + $content, + 'Controller НЕ должен логировать полный response от Telegram (утечка токена #3)' + ); + } + + /** + * #4: Миграция не должна содержать хардкод схемы 'erp24.' + */ + public function testMigrationDoesNotHardcodeSchema(): void + { + $content = file_get_contents($this->migrationPath); + + $this->assertStringNotContainsString( + "'erp24.upload_speed_tests'", + $content, + 'Миграция НЕ должна хардкодить схему erp24 (#4)' + ); + } + + /** + * #5: actionSpeedTest не должен загружать rawBody в память + */ + public function testControllerDoesNotLoadRawBodyInSpeedTest(): void + { + $content = file_get_contents($this->controllerPath); + + // Ищем rawBody в контексте actionSpeedTest (не actionSpeedTestReport) + $speedTestPos = strpos($content, 'function actionSpeedTest()'); + $speedTestReportPos = strpos($content, 'function actionSpeedTestReport()'); + + $speedTestBody = substr($content, $speedTestPos, $speedTestReportPos - $speedTestPos); + + $this->assertStringNotContainsString( + 'rawBody', + $speedTestBody, + 'actionSpeedTest НЕ должен загружать rawBody (#5)' + ); + } + + /** + * #6: Controller должен использовать HTTP коды ошибок + */ + public function testControllerUsesProperHttpStatusCodes(): void + { + $content = file_get_contents($this->controllerPath); + + // Должен использовать statusCode или HttpException для ошибок + $this->assertStringContainsString( + 'statusCode', + $content, + 'Controller должен устанавливать HTTP status code для ошибок (#6)' + ); + } + + /** + * #10: beforeAction должен иметь корректный PHPDoc + */ + public function testBeforeActionHasCorrectPhpDoc(): void + { + $content = file_get_contents($this->controllerPath); + + // Ищем PHPDoc перед beforeAction + $beforeActionPos = strpos($content, 'public function beforeAction($action)'); + $docBlockStart = strrpos(substr($content, 0, $beforeActionPos), '/**'); + $docBlock = substr($content, $docBlockStart, $beforeActionPos - $docBlockStart); + + // PHPDoc не должен описывать "Тестовый экшен" + $this->assertStringNotContainsString( + 'Тестовый экшен', + $docBlock, + 'PHPDoc для beforeAction не должен описывать другой метод (#10)' + ); + + // Должен содержать @return bool + $this->assertStringContainsString( + '@return bool', + $docBlock, + 'beforeAction должен иметь @return bool (#10)' + ); + } + + /** + * #11: Email должен содержать setFrom + */ + public function testControllerEmailHasSetFrom(): void + { + $content = file_get_contents($this->controllerPath); + + $this->assertStringContainsString( + 'setFrom', + $content, + 'Отправка email должна содержать setFrom (#11)' + ); + } + + /** + * #12: View не должен использовать print_r без экранирования + */ + public function testCreateViewDoesNotUsePrintRWithoutEncoding(): void + { + $content = file_get_contents($this->createViewPath); + + // Не должно быть голого print_r($errors) без Html::encode + $this->assertStringNotContainsString( + 'print_r($errors)', + $content, + 'View НЕ должен использовать print_r($errors) без экранирования (#12)' + ); + } + + /** + * #17: Логирование должно корректно форматировать bool + */ + public function testControllerFormatsBoolean(): void + { + $content = file_get_contents($this->controllerPath); + + // Не должно быть saved={$saved} (bool интерполяция) + $this->assertStringNotContainsString( + 'saved={$saved}', + $content, + 'Логирование НЕ должно интерполировать bool напрямую (#17)' + ); + } +} diff --git a/erp24/tests/unit/records/UploadSpeedTestTest.php b/erp24/tests/unit/records/UploadSpeedTestTest.php new file mode 100644 index 00000000..082ec491 --- /dev/null +++ b/erp24/tests/unit/records/UploadSpeedTestTest.php @@ -0,0 +1,565 @@ +assertSame('upload_speed_tests', UploadSpeedTest::tableName()); + } + + /** + * Тест: rules содержит обязательные поля + */ + public function testRulesContainsRequiredFields(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $requiredFields = []; + foreach ($rules as $rule) { + if ($rule[1] === 'required') { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + $requiredFields = array_merge($requiredFields, $fields); + } + } + + $this->assertContains('ip', $requiredFields, 'ip должен быть обязательным'); + $this->assertContains('speed_mbps', $requiredFields, 'speed_mbps должен быть обязательным'); + $this->assertContains('elapsed_sec', $requiredFields, 'elapsed_sec должен быть обязательным'); + $this->assertContains('test_size_mb', $requiredFields, 'test_size_mb должен быть обязательным'); + $this->assertContains('verdict', $requiredFields, 'verdict должен быть обязательным'); + } + + /** + * Тест: rules содержит валидацию integer для admin_id + */ + public function testRulesContainsIntegerForAdminId(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $integerFields = []; + foreach ($rules as $rule) { + if ($rule[1] === 'integer') { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + $integerFields = array_merge($integerFields, $fields); + } + } + + $this->assertContains('admin_id', $integerFields, 'admin_id должен быть integer'); + } + + /** + * Тест: rules содержит валидацию number для числовых полей + */ + public function testRulesContainsNumberForDecimalFields(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $numberFields = []; + foreach ($rules as $rule) { + if ($rule[1] === 'number') { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + $numberFields = array_merge($numberFields, $fields); + } + } + + $this->assertContains('speed_mbps', $numberFields, 'speed_mbps должен быть number'); + $this->assertContains('elapsed_sec', $numberFields, 'elapsed_sec должен быть number'); + $this->assertContains('test_size_mb', $numberFields, 'test_size_mb должен быть number'); + } + + /** + * Тест: rules содержит валидацию boolean для telegram_sent и email_sent + */ + public function testRulesContainsBooleanForSentFlags(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $booleanFields = []; + foreach ($rules as $rule) { + if ($rule[1] === 'boolean') { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + $booleanFields = array_merge($booleanFields, $fields); + } + } + + $this->assertContains('telegram_sent', $booleanFields, 'telegram_sent должен быть boolean'); + $this->assertContains('email_sent', $booleanFields, 'email_sent должен быть boolean'); + } + + /** + * Тест: rules содержит ограничение длины ip (max 45) + */ + public function testRulesContainsMaxLengthForIp(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $found = false; + foreach ($rules as $rule) { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + if (in_array('ip', $fields) && $rule[1] === 'string' && isset($rule['max']) && $rule['max'] === 45) { + $found = true; + break; + } + } + + $this->assertTrue($found, 'ip должен иметь ограничение max=45'); + } + + /** + * Тест: rules содержит ограничение длины verdict (max 50) + */ + public function testRulesContainsMaxLengthForVerdict(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $found = false; + foreach ($rules as $rule) { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + if (in_array('verdict', $fields) && $rule[1] === 'string' && isset($rule['max']) && $rule['max'] === 50) { + $found = true; + break; + } + } + + $this->assertTrue($found, 'verdict должен иметь ограничение max=50'); + } + + // ======================================================================== + // Тесты валидации verdict по белому списку (#8) + // ======================================================================== + + /** + * Тест: rules содержит 'in' валидатор для verdict + */ + public function testRulesContainsInValidatorForVerdict(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $found = false; + foreach ($rules as $rule) { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + if (in_array('verdict', $fields) && $rule[1] === 'in' && isset($rule['range'])) { + $expectedRange = ['Быстро', 'Нормально', 'Медленно', 'Очень медленно']; + $this->assertEquals($expectedRange, $rule['range'], 'Допустимые verdict должны быть: Быстро, Нормально, Медленно, Очень медленно'); + $found = true; + break; + } + } + + $this->assertTrue($found, 'verdict должен валидироваться по белому списку (in)'); + } + + /** + * Тест: произвольный verdict не проходит валидацию + */ + public function testInvalidVerdictFailsValidation(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 1.0; + $model->elapsed_sec = 2.0; + $model->test_size_mb = 2.0; + $model->verdict = 'Произвольный текст'; + + $this->assertFalse($model->validate(), 'Произвольный verdict не должен пройти валидацию'); + $this->assertArrayHasKey('verdict', $model->getErrors()); + } + + /** + * Тест: XSS-подобный verdict не проходит валидацию + */ + public function testXssVerdictFailsValidation(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 1.0; + $model->elapsed_sec = 2.0; + $model->test_size_mb = 2.0; + $model->verdict = ''; + + $this->assertFalse($model->validate(), 'XSS-verdict не должен пройти валидацию'); + } + + // ======================================================================== + // Тесты min/max для числовых полей (#9) + // ======================================================================== + + /** + * Тест: rules содержит min>=0 для speed_mbps + */ + public function testRulesContainsMinForSpeedMbps(): void + { + $model = new UploadSpeedTest(); + $rules = $model->rules(); + + $found = false; + foreach ($rules as $rule) { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + if (in_array('speed_mbps', $fields) && $rule[1] === 'number' && isset($rule['min'])) { + $this->assertGreaterThanOrEqual(0, $rule['min'], 'speed_mbps min должен быть >= 0'); + $found = true; + break; + } + } + + $this->assertTrue($found, 'speed_mbps должен иметь ограничение min'); + } + + /** + * Тест: отрицательная скорость не проходит валидацию + */ + public function testNegativeSpeedFailsValidation(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = -1.0; + $model->elapsed_sec = 2.0; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + + $this->assertFalse($model->validate(), 'Отрицательная скорость не должна пройти валидацию'); + $this->assertArrayHasKey('speed_mbps', $model->getErrors()); + } + + /** + * Тест: отрицательное время не проходит валидацию + */ + public function testNegativeElapsedFailsValidation(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 1.0; + $model->elapsed_sec = -5.0; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + + $this->assertFalse($model->validate(), 'Отрицательное время не должно пройти валидацию'); + $this->assertArrayHasKey('elapsed_sec', $model->getErrors()); + } + + /** + * Тест: нулевой размер файла не проходит валидацию + */ + public function testZeroTestSizeFailsValidation(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 1.0; + $model->elapsed_sec = 2.0; + $model->test_size_mb = 0; + $model->verdict = 'Быстро'; + + $this->assertFalse($model->validate(), 'Нулевой размер теста не должен пройти валидацию'); + $this->assertArrayHasKey('test_size_mb', $model->getErrors()); + } + + /** + * Тест: запредельно большая скорость не проходит валидацию + */ + public function testExcessiveSpeedFailsValidation(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 100000; + $model->elapsed_sec = 0.01; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + + $this->assertFalse($model->validate(), 'Запредельная скорость (100000 МБ/с) не должна пройти валидацию'); + $this->assertArrayHasKey('speed_mbps', $model->getErrors()); + } + + // ======================================================================== + // Тесты attributeLabels + // ======================================================================== + + /** + * Тест: attributeLabels содержит все основные поля + */ + public function testAttributeLabelsContainsAllFields(): void + { + $model = new UploadSpeedTest(); + $labels = $model->attributeLabels(); + + $expectedAttributes = [ + 'id', 'admin_id', 'admin_name', 'ip', 'forwarded_for', + 'user_agent', 'screen', 'page_url', 'speed_mbps', 'elapsed_sec', + 'test_size_mb', 'verdict', 'telegram_sent', 'email_sent', 'created_at', + ]; + + foreach ($expectedAttributes as $attribute) { + $this->assertArrayHasKey($attribute, $labels, "Метка для {$attribute} должна существовать"); + } + } + + /** + * Тест: метки на русском языке + */ + public function testAttributeLabelsAreInRussian(): void + { + $model = new UploadSpeedTest(); + $labels = $model->attributeLabels(); + + $this->assertSame('IP-адрес', $labels['ip']); + $this->assertSame('Заключение', $labels['verdict']); + $this->assertSame('Скорость (МБ/с)', $labels['speed_mbps']); + $this->assertSame('Дата теста', $labels['created_at']); + } + + // ======================================================================== + // Тесты валидации данных + // ======================================================================== + + /** + * Тест: валидация проходит для корректных данных + */ + public function testValidationPassesForValidData(): void + { + $model = new UploadSpeedTest(); + $model->ip = '192.168.1.1'; + $model->speed_mbps = 12.34; + $model->elapsed_sec = 0.16; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + $model->admin_id = 1; + $model->admin_name = 'Test Admin'; + $model->telegram_sent = false; + $model->email_sent = false; + + $this->assertTrue($model->validate(), 'Валидация должна пройти для корректных данных: ' . json_encode($model->getErrors())); + } + + /** + * Тест: валидация не проходит без обязательных полей + */ + public function testValidationFailsWithoutRequiredFields(): void + { + $model = new UploadSpeedTest(); + + $this->assertFalse($model->validate(), 'Пустая модель не должна пройти валидацию'); + + $errors = $model->getErrors(); + $this->assertArrayHasKey('ip', $errors, 'Ошибка для ip должна быть'); + $this->assertArrayHasKey('speed_mbps', $errors, 'Ошибка для speed_mbps должна быть'); + $this->assertArrayHasKey('elapsed_sec', $errors, 'Ошибка для elapsed_sec должна быть'); + $this->assertArrayHasKey('test_size_mb', $errors, 'Ошибка для test_size_mb должна быть'); + $this->assertArrayHasKey('verdict', $errors, 'Ошибка для verdict должна быть'); + } + + /** + * Тест: валидация не проходит при нечисловом speed_mbps + */ + public function testValidationFailsForNonNumericSpeed(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 'abc'; + $model->elapsed_sec = 1.0; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + + $this->assertFalse($model->validate(), 'Нечисловое speed_mbps не должно пройти валидацию'); + $this->assertArrayHasKey('speed_mbps', $model->getErrors()); + } + + /** + * Тест: admin_id может быть null + */ + public function testAdminIdCanBeNull(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 5.0; + $model->elapsed_sec = 0.4; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + $model->admin_id = null; + + $this->assertTrue($model->validate(), 'admin_id=null должен пройти валидацию: ' . json_encode($model->getErrors())); + } + + /** + * Тест: verdict не должен превышать 50 символов + */ + public function testVerdictMaxLength(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 1.0; + $model->elapsed_sec = 2.0; + $model->test_size_mb = 2.0; + $model->verdict = str_repeat('А', 51); // 51 символ — превышает лимит + + $this->assertFalse($model->validate(), 'verdict длиннее 50 символов не должен пройти'); + $this->assertArrayHasKey('verdict', $model->getErrors()); + } + + /** + * Тест: ip поддерживает IPv6 (max 45 символов) + */ + public function testIpSupportsIpv6(): void + { + $model = new UploadSpeedTest(); + $model->ip = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; // IPv6 — 39 символов + $model->speed_mbps = 1.0; + $model->elapsed_sec = 2.0; + $model->test_size_mb = 2.0; + $model->verdict = 'Нормально'; + + $this->assertTrue($model->validate(), 'IPv6 адрес должен пройти валидацию: ' . json_encode($model->getErrors())); + } + + /** + * Тест: валидация для всех допустимых вердиктов + * + * @dataProvider validVerdictProvider + */ + public function testValidVerdicts(string $verdict): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 1.0; + $model->elapsed_sec = 2.0; + $model->test_size_mb = 2.0; + $model->verdict = $verdict; + + $this->assertTrue($model->validate(), "Вердикт '{$verdict}' должен пройти валидацию: " . json_encode($model->getErrors())); + } + + /** + * Провайдер данных для допустимых вердиктов + */ + public static function validVerdictProvider(): array + { + return [ + 'быстро' => ['Быстро'], + 'нормально' => ['Нормально'], + 'медленно' => ['Медленно'], + 'очень медленно' => ['Очень медленно'], + ]; + } + + // ======================================================================== + // Тесты соответствия миграции и модели + // ======================================================================== + + /** + * Тест: все атрибуты из миграции присутствуют в attributeLabels + */ + public function testAllMigrationColumnsHaveLabels(): void + { + $expectedColumns = [ + 'id', 'admin_id', 'admin_name', 'ip', 'forwarded_for', + 'user_agent', 'screen', 'page_url', 'speed_mbps', 'elapsed_sec', + 'test_size_mb', 'verdict', 'telegram_sent', 'email_sent', 'created_at', + ]; + + $model = new UploadSpeedTest(); + $labels = $model->attributeLabels(); + + foreach ($expectedColumns as $column) { + $this->assertArrayHasKey( + $column, + $labels, + "Столбец '{$column}' из миграции должен иметь метку в attributeLabels" + ); + } + } + + /** + * Тест: модель может быть создана и заполнена данными + */ + public function testModelCanBePopulatedWithData(): void + { + $model = new UploadSpeedTest(); + $model->admin_id = 42; + $model->admin_name = 'Иванов Иван'; + $model->ip = '192.168.1.100'; + $model->forwarded_for = '10.0.0.1, 172.16.0.1'; + $model->user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'; + $model->screen = '1920x1080'; + $model->page_url = '/write-offs-erp/create'; + $model->speed_mbps = 8.75; + $model->elapsed_sec = 0.23; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + $model->telegram_sent = true; + $model->email_sent = false; + + $this->assertSame(42, $model->admin_id); + $this->assertSame('Иванов Иван', $model->admin_name); + $this->assertSame('192.168.1.100', $model->ip); + $this->assertSame('10.0.0.1, 172.16.0.1', $model->forwarded_for); + $this->assertSame('1920x1080', $model->screen); + $this->assertEquals(8.75, $model->speed_mbps); + $this->assertSame('Быстро', $model->verdict); + $this->assertTrue($model->telegram_sent); + $this->assertFalse($model->email_sent); + } + + /** + * Тест: ip слишком длинный (> 45) не проходит валидацию + */ + public function testIpMaxLength(): void + { + $model = new UploadSpeedTest(); + $model->ip = str_repeat('1', 46); + $model->speed_mbps = 1.0; + $model->elapsed_sec = 1.0; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + + $this->assertFalse($model->validate(), 'ip длиннее 45 символов не должен пройти'); + $this->assertArrayHasKey('ip', $model->getErrors()); + } + + /** + * Тест: page_url ограничен 500 символами + */ + public function testPageUrlMaxLength(): void + { + $model = new UploadSpeedTest(); + $model->ip = '10.0.0.1'; + $model->speed_mbps = 1.0; + $model->elapsed_sec = 1.0; + $model->test_size_mb = 2.0; + $model->verdict = 'Быстро'; + $model->page_url = str_repeat('a', 501); + + $this->assertFalse($model->validate(), 'page_url длиннее 500 символов не должен пройти'); + $this->assertArrayHasKey('page_url', $model->getErrors()); + } +} diff --git a/erp24/views/write_offs_erp/create.php b/erp24/views/write_offs_erp/create.php index 87493e3c..355f7eff 100644 --- a/erp24/views/write_offs_erp/create.php +++ b/erp24/views/write_offs_erp/create.php @@ -15,16 +15,29 @@ use yii\helpers\Html; $this->title = 'Создание документа списания в ERP'; $this->params['breadcrumbs'][] = ['label' => 'Write Offs Erps', 'url' => ['index']]; $this->params['breadcrumbs'][] = $this->title; +$this->registerJsFile('/js/upload-speed-test.js', ['position' => \yii\web\View::POS_END]); ?>

title) ?>

+
+ + +
+
+ Ошибка, обратитесь к администратору.Ошибка, обратитесь к администратору. diff --git a/erp24/views/write_offs_erp/update.php b/erp24/views/write_offs_erp/update.php index cbdce382..42e57d80 100644 --- a/erp24/views/write_offs_erp/update.php +++ b/erp24/views/write_offs_erp/update.php @@ -44,9 +44,7 @@ $this->registerJsFile('/js/write-offs-erp/update.js', ['position' => \yii\web\Vi Ошибка, обратитесь к администратору.Ошибка, обратитесь к администратору. diff --git a/erp24/web/js/heic_to_jpg_replace.js b/erp24/web/js/heic_to_jpg_replace.js index 3da5ec52..927ff7e6 100644 --- a/erp24/web/js/heic_to_jpg_replace.js +++ b/erp24/web/js/heic_to_jpg_replace.js @@ -1,35 +1,68 @@ /* jshint esversion: 6 */ -$(document).ready(() => { - document.querySelectorAll('input[type=file]')?.forEach(x => x.addEventListener('change', function (e) { - const target = e.target; - for (let i = 0; i < this.files.length; i++) { - const image = this.files[i]; - const ext = image.name.substring(image.name.lastIndexOf('.') + 1); +// --- [9] Делегированный обработчик HEIC → JPEG --- +// Используем делегирование через $(document).on() вместо querySelectorAll, +// чтобы обработчик работал и для динамически добавленных строк (unclead/multipleinput). +$(document).on('change', 'input[type=file]', function (e) { + const target = e.target; + const files = target.files; + if (!files || files.length === 0) return; - let reader = new FileReader(); + // Проверяем, есть ли HEIC файлы среди выбранных + let hasHeic = false; + for (let i = 0; i < files.length; i++) { + if (files[i].name.toLowerCase().endsWith('.heic')) { + hasHeic = true; + break; + } + } + + if (!hasHeic) return; + + // [10] Инкремент счётчика — HEIC-конвертация началась + window._pendingFileOps = (window._pendingFileOps || 0) + 1; - if (ext === "heic") { + // Собираем все файлы: не-HEIC оставляем как есть, HEIC конвертируем в JPEG + const container = new DataTransfer(); + const heicPromises = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.name.toLowerCase().endsWith('.heic')) { + heicPromises.push( heic2any({ - blob: image, + blob: file, toType: "image/jpeg", quality: 0.5 }) .then(function (resultBlob) { - const url = URL.createObjectURL(resultBlob); - let container = new DataTransfer(); - let file = new File([resultBlob], image.name + ".jpg", { + return new File([resultBlob], file.name + ".jpg", { type: "image/jpeg", lastModified: new Date().getTime() }); - container.items.add(file); - - target.files = container.files; }) .catch(function (x) { - alert("Ошибка конвертации HEIC. " + x.code + " - " + x.message); - }); - } + alert("Ошибка конвертации HEIC: " + file.name + ". " + x.code + " - " + x.message); + return null; + }) + ); + } else { + container.items.add(file); + } + } + + Promise.all(heicPromises).then(function (converted) { + converted.forEach(function (f) { + if (f) container.items.add(f); + }); + target.files = container.files; + + // Кэшируем конвертированные файлы для защиты от ERR_UPLOAD_FILE_CHANGED + if (typeof cacheFilesFromInput === 'function') { + cacheFilesFromInput(target); } - })); -}) \ No newline at end of file + }).finally(function () { + // [10] Декремент — HEIC-конвертация завершена + window._pendingFileOps = Math.max(0, (window._pendingFileOps || 0) - 1); + }); +}); diff --git a/erp24/web/js/upload-speed-test.js b/erp24/web/js/upload-speed-test.js new file mode 100644 index 00000000..1832d374 --- /dev/null +++ b/erp24/web/js/upload-speed-test.js @@ -0,0 +1,183 @@ +(function () { + 'use strict'; + + var TEST_SIZE_MB = 2; + var TEST_URL = '/write-offs-erp/speed-test'; + var REPORT_URL = '/write-offs-erp/speed-test-report'; + + var THRESHOLDS = { + fast: 5, + normal: 1, + slow: 0.3 + }; + + function getVerdict(speedMbps) { + if (speedMbps >= THRESHOLDS.fast) { + return {text: 'Быстро', css: 'success', icon: '\u2705', detail: 'Загрузка фото и видео (160\u2013300 МБ) будет работать без проблем.'}; + } + if (speedMbps >= THRESHOLDS.normal) { + return {text: 'Нормально', css: 'info', icon: '\u2139\uFE0F', detail: 'Загрузка фото будет работать нормально. Видео 160\u2013300 МБ может загружаться несколько минут.'}; + } + if (speedMbps >= THRESHOLDS.slow) { + return {text: 'Медленно', css: 'warning', icon: '\u26A0\uFE0F', detail: 'Рекомендуется загружать не более 2\u20133 фото за раз. Видео 160\u2013300 МБ лучше сжимать перед загрузкой.'}; + } + return {text: 'Очень медленно', css: 'danger', icon: '\u274C', detail: 'Загрузка файлов может прерываться по таймауту. Видео загрузить не получится. Проверьте интернет-соединение.'}; + } + + function formatSpeed(speedMbps) { + if (speedMbps >= 1) { + return speedMbps.toFixed(1) + ' МБ/с'; + } + return (speedMbps * 1024).toFixed(0) + ' КБ/с'; + } + + function formatTime(seconds) { + if (seconds < 1) { + return (seconds * 1000).toFixed(0) + ' мс'; + } + if (seconds < 60) { + return seconds.toFixed(1) + ' сек'; + } + var min = Math.floor(seconds / 60); + var sec = Math.round(seconds % 60); + return min + ' мин ' + sec + ' сек'; + } + + function estimateUpload(speedMbps, fileSizeMb) { + if (speedMbps <= 0) return '—'; + var seconds = fileSizeMb / speedMbps; + return formatTime(seconds); + } + + function createTestBlob(sizeMb) { + var bytes = sizeMb * 1024 * 1024; + var arr = new Uint8Array(bytes); + for (var i = 0; i < bytes; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + return new Blob([arr], {type: 'application/octet-stream'}); + } + + function sendReport(speedMbps, elapsed, verdict) { + var data = JSON.stringify({ + speed_mbps: parseFloat(speedMbps.toFixed(2)), + elapsed_sec: parseFloat(elapsed.toFixed(2)), + test_size_mb: TEST_SIZE_MB, + verdict: verdict.text, + user_agent: navigator.userAgent, + screen: screen.width + 'x' + screen.height, + page_url: window.location.href + }); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', REPORT_URL, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(data); + } + + function runSpeedTest(btn) { + var container = document.getElementById('speed-test-result'); + var progressWrap = document.getElementById('speed-test-progress'); + var progressBar = document.getElementById('speed-test-bar'); + var progressText = document.getElementById('speed-test-progress-text'); + + btn.disabled = true; + btn.textContent = 'Тестирование...'; + container.innerHTML = ''; + progressWrap.style.display = 'block'; + progressBar.style.width = '0%'; + progressText.textContent = 'Подготовка тестового файла (' + TEST_SIZE_MB + ' МБ)...'; + + var blob = createTestBlob(TEST_SIZE_MB); + var formData = new FormData(); + formData.append('test_file', blob, 'speed_test.bin'); + + var csrfParam = document.querySelector('meta[name="csrf-param"]'); + var csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (csrfParam && csrfToken) { + formData.append(csrfParam.content, csrfToken.content); + } + + var xhr = new XMLHttpRequest(); + var startTime = Date.now(); + + xhr.upload.addEventListener('progress', function (e) { + if (e.lengthComputable) { + var percent = (e.loaded / e.total * 100).toFixed(0); + progressBar.style.width = percent + '%'; + progressBar.textContent = percent + '%'; + + var elapsed = (Date.now() - startTime) / 1000; + var speedMbps = elapsed > 0 ? (e.loaded / 1024 / 1024) / elapsed : 0; + progressText.textContent = formatSpeed(speedMbps) + ' — загружено ' + percent + '%'; + } + }); + + xhr.addEventListener('load', function () { + var elapsed = (Date.now() - startTime) / 1000; + var speedMbps = elapsed > 0 ? TEST_SIZE_MB / elapsed : 0; + var verdict = getVerdict(speedMbps); + + progressBar.style.width = '100%'; + progressBar.textContent = '100%'; + progressBar.className = 'progress-bar bg-' + verdict.css; + progressText.textContent = ''; + + container.innerHTML = + '
' + + '
' + + '
' + verdict.icon + ' ' + verdict.text + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
Скорость загрузки' + formatSpeed(speedMbps) + '
Тестовый файл' + TEST_SIZE_MB + ' МБ
Время загрузки' + formatTime(elapsed) + '
1 фото (~3 МБ)~' + estimateUpload(speedMbps, 3) + '
10 фото (~30 МБ)~' + estimateUpload(speedMbps, 30) + '
Видео мин. (~160 МБ)~' + estimateUpload(speedMbps, 160) + '
Видео макс. (~300 МБ)~' + estimateUpload(speedMbps, 300) + '
' + + '

' + verdict.detail + '

' + + '
' + + '
'; + + sendReport(speedMbps, elapsed, verdict); + + btn.disabled = false; + btn.textContent = 'Тест скорости загрузки'; + }); + + xhr.addEventListener('error', function () { + progressBar.style.width = '100%'; + progressBar.className = 'progress-bar bg-danger'; + progressText.textContent = ''; + container.innerHTML = + '
' + + 'Ошибка сети. Не удалось выполнить тест. Проверьте интернет-соединение.' + + '
'; + btn.disabled = false; + btn.textContent = 'Тест скорости загрузки'; + }); + + xhr.addEventListener('timeout', function () { + container.innerHTML = + '
' + + 'Таймаут. Сервер не ответил вовремя. Скорость загрузки очень низкая.' + + '
'; + btn.disabled = false; + btn.textContent = 'Тест скорости загрузки'; + }); + + xhr.open('POST', TEST_URL, true); + xhr.timeout = 120000; + xhr.send(formData); + } + + document.addEventListener('DOMContentLoaded', function () { + var btn = document.getElementById('btn-speed-test'); + if (btn) { + btn.addEventListener('click', function () { + runSpeedTest(btn); + }); + } + }); +})(); diff --git a/erp24/web/js/validate/validateForm.js b/erp24/web/js/validate/validateForm.js index 288bbc98..63c642d2 100755 --- a/erp24/web/js/validate/validateForm.js +++ b/erp24/web/js/validate/validateForm.js @@ -1,5 +1,234 @@ +/* jshint esversion: 6 */ + let initialCommentValue = ''; +// --- [10] Счётчик незавершённых файловых операций (кэширование / HEIC-конвертация) --- +// submitFormWithXHR блокируется, пока _pendingFileOps > 0 +window._pendingFileOps = 0; + +// --- Кэш файлов: защита от ERR_UPLOAD_FILE_CHANGED --- +// Файлы читаются в память (ArrayBuffer → File) сразу при выборе. +// При отправке формы используются кэшированные копии, а не ссылки на диск. +var fileCache = new WeakMap(); + +function cacheFilesFromInput(input) { + var files = input.files; + if (!files || files.length === 0) { + fileCache.delete(input); + return Promise.resolve(); + } + + // [10] Инкремент счётчика — операция началась + window._pendingFileOps++; + + var promises = []; + for (var i = 0; i < files.length; i++) { + (function (file) { + var p = new Promise(function (resolve) { + try { + var reader = new FileReader(); + reader.onload = function () { + resolve(new File([reader.result], file.name, { + type: file.type, + lastModified: file.lastModified + })); + }; + reader.onerror = function () { + resolve(null); + }; + reader.readAsArrayBuffer(file); + } catch (e) { + resolve(null); + } + }); + promises.push(p); + })(files[i]); + } + return Promise.all(promises).then(function (cachedFiles) { + var valid = cachedFiles.filter(function (f) { return f !== null; }); + if (valid.length > 0) { + fileCache.set(input, valid); + } + }).finally(function () { + // [10] Декремент — операция завершена (успешно или нет) + window._pendingFileOps = Math.max(0, window._pendingFileOps - 1); + }); +} + +// --- [5] Делегированный обработчик: кэшируем файлы при выборе --- +// Для HEIC файлов ждём 500ms (heic_to_jpg_replace.js конвертирует и сам вызывает cacheFilesFromInput). +// Для обычных файлов кэшируем сразу, без задержки. +$(document).on('change', 'input[type="file"]', function () { + var input = this; + var files = input.files; + + // Проверяем наличие HEIC среди выбранных файлов + var hasHeic = false; + if (files) { + for (var i = 0; i < files.length; i++) { + if (files[i].name.toLowerCase().endsWith('.heic')) { + hasHeic = true; + break; + } + } + } + + if (hasHeic) { + // HEIC: даём время на конвертацию (heic_to_jpg_replace.js вызовет cacheFilesFromInput) + setTimeout(function () { + cacheFilesFromInput(input); + }, 500); + } else { + // Обычные файлы: кэшируем немедленно + cacheFilesFromInput(input); + } +}); + +// --- [10] Синхронная проверка: есть ли в кэше файлы для всех inputs с выбранными файлами --- +function validateFileCache(yiiform) { + var errors = []; + + // Блокируем, если ещё идёт кэширование / конвертация + if (window._pendingFileOps > 0) { + errors.push('Файлы ещё обрабатываются. Подождите несколько секунд и попробуйте снова.'); + return errors; + } + + yiiform.find('input[type="file"]').each(function () { + var cached = fileCache.get(this); + if (this.files && this.files.length > 0 && (!cached || cached.length === 0)) { + var name = (this.files[0] && this.files[0].name) || 'unknown'; + errors.push('Файл "' + name + '" не удалось закэшировать. Пожалуйста, выберите файл заново.'); + } + }); + return errors; +} + +// --- Отправка формы через XHR + FormData (файлы из кэша, не с диска) --- +function submitFormWithXHR(yiiform) { + // [10] Если идёт кэширование — ждём и повторяем + if (window._pendingFileOps > 0) { + var waitBtn = yiiform.find('.submitter'); + waitBtn.prop('disabled', true).text('Ожидание обработки файлов...'); + setTimeout(function () { + submitFormWithXHR(yiiform); + }, 300); + return; + } + + var form = yiiform[0]; + var formData = new FormData(); + + // Собираем все НЕ-файловые поля + var serialized = yiiform.serializeArray(); + for (var i = 0; i < serialized.length; i++) { + formData.append(serialized[i].name, serialized[i].value); + } + + // [4] Собираем файлы из кэша (fallback — из input.files с предупреждением) + var fileInputs = yiiform.find('input[type="file"]'); + fileInputs.each(function () { + var input = this; + var name = input.name; + if (!name) return; + var cached = fileCache.get(input); + if (cached && cached.length > 0) { + for (var j = 0; j < cached.length; j++) { + formData.append(name, cached[j], cached[j].name); + } + } else if (input.files && input.files.length > 0) { + // Fallback: файлы с диска — может вызвать ERR_UPLOAD_FILE_CHANGED + console.warn('[validateForm] Файл не найден в кэше, используем input.files (возможен ERR_UPLOAD_FILE_CHANGED):', name); + for (var j = 0; j < input.files.length; j++) { + formData.append(name, input.files[j], input.files[j].name); + } + } + }); + + // Индикатор загрузки + var submitBtn = yiiform.find('.submitter'); + var originalText = submitBtn.text(); + submitBtn.prop('disabled', true).text('Загрузка...'); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', form.action, true); + + xhr.onload = function () { + if (xhr.status === 200) { + var responseURL = xhr.responseURL || ''; + if (responseURL && responseURL !== window.location.href) { + // Сервер вернул redirect (302 → XHR следует за ним автоматически) + window.location.href = responseURL; + } else { + // [6] Сервер вернул HTML (ошибка валидации) — перезагружаем страницу + // Вместо document.write() (XSS-вектор) делаем reload + window.location.reload(); + } + } else { + alert('Ошибка сервера: ' + xhr.status); + submitBtn.prop('disabled', false).text(originalText); + } + }; + + xhr.onerror = function () { + alert('Ошибка сети. Проверьте подключение и попробуйте снова.'); + submitBtn.prop('disabled', false).text(originalText); + }; + + xhr.send(formData); +} + +// --- [1] Перехват beforeSubmit Yii2 ActiveForm --- +// Yii2 ActiveForm (yii.activeForm.js:447) проверяет event.result перед нативным submit. +// Возврат false блокирует нативный form.submit(). +$(document).on('beforeSubmit', '#dynamic-form', function (e) { + e.preventDefault(); + return false; +}); + +// --- [2] Блокировка нативного submit формы (делегированный) --- +// Yii2 ActiveForm с enableAjaxValidation:true после AJAX-валидации вызывает +// нативный form.submit(), который читает файлы с диска → ERR_UPLOAD_FILE_CHANGED. +// Все отправки должны идти ТОЛЬКО через submitFormWithXHR (файлы из памяти). +$(document).on('submit', '#dynamic-form', function (e) { + e.preventDefault(); + return false; +}); + +// --- [2] Прямой submit handler (после DOM ready) --- +$(document).ready(function () { + var $form = $('#dynamic-form'); + if ($form.length) { + // Прямой beforeSubmit (более высокий приоритет чем делегированный) + $form.on('beforeSubmit', function (e) { + e.preventDefault(); + return false; + }); + + // Прямой submit handler + $form.on('submit', function (e) { + e.preventDefault(); + return false; + }); + } +}); + +// --- [7] Re-cache при событиях динамической формы --- +// unclead/multipleinput пересоздаёт DOM-элементы при добавлении/удалении строк. +// WeakMap теряет ссылку на старые input'ы → нужно пере-кэшировать. +$(document).on('afterAddRow afterDeleteRow', '.multiple-input-list', function () { + var $form = $('#dynamic-form'); + if (!$form.length) return; + + $form.find('input[type="file"]').each(function () { + if (this.files && this.files.length > 0 && !fileCache.has(this)) { + cacheFilesFromInput(this); + } + }); +}); + +// --- Основная логика формы --- + function updateCommentReadonly() { const currentUser = $('.admin-name').text().trim(); const allowedCommentUsers = ['Емельянова Ольга', 'Яшенкова Алёна', 'Ольга Цветкова']; @@ -18,11 +247,17 @@ $(document).ready(function () { }); }); -$(".form-validate button").click(function (e) { +// --- [8] Обработчик кнопки «Сохранить» --- +// Делегированный, привязан к конкретному классу .submitter (не ко всем button в форме), +// чтобы не перехватывать кнопки kartik FileInput (+/-). +$(document).on('click', '.form-validate .submitter', function (e) { e.preventDefault(); var form = $(this).closest('form')[0]; var yiiform = $("#" + form.id); + // Сбрасываем флаг переноса — кнопка «Сохранить» всегда сохраняет, не переносит + $('#do-transfer').val(0); + $('.messages').html(''); yiiform.find('.has-error').removeClass('has-error'); yiiform.find('.help-block').remove(); @@ -89,8 +324,17 @@ $(".form-validate button").click(function (e) { alert(allErrors.join('\n')); } else { - yiiform.off('submit'); - yiiform.submit(); + // Проверяем кэш файлов и отправляем через XHR + var cacheErrors = validateFileCache(yiiform); + if (cacheErrors.length > 0) { + if (!errors['__common']) errors = {'__common': []}; + errors['__common'] = errors['__common'] || []; + errors['__common'] = errors['__common'].concat(cacheErrors); + renderErrors(errors); + alert(cacheErrors.join('\n')); + } else { + submitFormWithXHR(yiiform); + } } }) .fail(function () { @@ -121,4 +365,4 @@ $(".form-validate button").click(function (e) { var strOut2 = messages.join(',
'); $('.messages').html(strOut2); } -}); \ No newline at end of file +}); diff --git a/erp24/web/js/write-offs-erp/update.js b/erp24/web/js/write-offs-erp/update.js index 3cb1e6f5..305a6712 100644 --- a/erp24/web/js/write-offs-erp/update.js +++ b/erp24/web/js/write-offs-erp/update.js @@ -1,18 +1,6 @@ (function(){ const form = $('#dynamic-form'); - form.on('submit', function (e) { - const wantTransfer = $('#do-transfer').val() === '1'; - if (wantTransfer) { - const anyChecked = $('.transfer-checkbox:checked').length > 0; - if (!anyChecked) { - e.preventDefault(); - e.stopImmediatePropagation(); - alert('Отметьте хотя бы одну позицию для переноса.'); - $('#do-transfer').val(0); - return false; - } - } - }); + $('#btn-transfer').on('click', function(e){ e.preventDefault(); e.stopImmediatePropagation(); @@ -23,17 +11,14 @@ return false; } - if(!confirm('Перенести отмеченные позиции в новый документ?')){ return; } $('#do-transfer').val(1); - form.trigger('submit'); - }); - - $('.submitter').on('click', function(e){ - $('#do-transfer').val(0); - $('#dynamic-form').trigger('submit'); + // Отправляем через XHR (как и сохранение), чтобы избежать ERR_UPLOAD_FILE_CHANGED + if (typeof submitFormWithXHR === 'function') { + submitFormWithXHR(form); + } }); -})(); \ No newline at end of file +})(); diff --git a/erp24/web/speed-test.html b/erp24/web/speed-test.html new file mode 100644 index 00000000..49effb1e --- /dev/null +++ b/erp24/web/speed-test.html @@ -0,0 +1,32 @@ + + + + + + Тест скорости загрузки файлов — ERP24 + + + +
+

Тест скорости загрузки файлов

+

Страница для проверки скорости upload на сервер ERP24.

+
+ +
+ + +
+
+
+ + + + -- 2.39.5