# PHP Container Environment
APP_ENV=development
TZ=Europe/Moscow
+
+# RabbitMQ Configuration
+RABBIT_HOST=rabbitmq-yii_erp24
+RABBIT_USER=admin
+RABBIT_PASSWORD=dev_rabbit_password
// 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] Тест скорости загрузки файлов',
+ ],
];
],
],
'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'],
],
],
use yii\filters\VerbFilter;
use yii_app\records\WriteOffsProductsErp;
use yii_app\services\FileService;
+use yii_app\records\UploadSpeedTest;
use yii_app\services\TimetableService;
/**
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.
*
$processedAnalytics = $this->getProcessedAnalytics();
if ($this->request->isPost) {
+ $this->logFormSubmission('create');
+
if ($model->load(Yii::$app->request->post())) {
$modelsProducts = MultipleModel::createMultipleModel(WriteOffsProductsErp::classname(), 'WriteOffsErp', 'modelsProducts');
$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,
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 = [];
$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,
'listProductsDict' => $listProductsDict,
'balanceDict' => $balanceDict,
'processedAnalytics' => $processedAnalytics,
- 'errors' => $e->getMessage()
+ 'errors' => $errors
]);
}
}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Таблица для хранения результатов тестов скорости загрузки файлов.
+ */
+class m260215_200000_create_upload_speed_tests_table extends Migration
+{
+ const TABLE_NAME = 'upload_speed_tests';
+
+ public function safeUp()
+ {
+ $this->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);
+ }
+}
--- /dev/null
+<?php
+
+namespace yii_app\records;
+
+use Yii;
+
+/**
+ * ActiveRecord модель для таблицы upload_speed_tests.
+ * Хранит результаты тестов скорости загрузки файлов.
+ *
+ * @property int $id
+ * @property int|null $admin_id ID администратора
+ * @property string|null $admin_name Имя администратора
+ * @property string $ip IP-адрес клиента
+ * @property string|null $forwarded_for X-Forwarded-For заголовок
+ * @property string|null $user_agent User-Agent браузера
+ * @property string|null $screen Разрешение экрана
+ * @property string|null $page_url URL страницы запуска теста
+ * @property float $speed_mbps Скорость загрузки в МБ/с
+ * @property float $elapsed_sec Время загрузки тестового файла в секундах
+ * @property float $test_size_mb Размер тестового файла в МБ
+ * @property string $verdict Заключение: Быстро, Нормально, Медленно, Очень медленно
+ * @property bool $telegram_sent Отправлено ли в Telegram
+ * @property bool $email_sent Отправлено ли на email
+ * @property string $created_at Дата и время теста
+ */
+class UploadSpeedTest extends \yii\db\ActiveRecord
+{
+ /**
+ * {@inheritdoc}
+ */
+ public static function tableName()
+ {
+ return 'upload_speed_tests';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rules()
+ {
+ return [
+ [['ip', 'speed_mbps', 'elapsed_sec', 'test_size_mb', 'verdict'], 'required'],
+ [['admin_id'], 'integer'],
+ [['speed_mbps'], 'number', 'min' => 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']);
+ }
+}
$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();
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\controllers;
+
+use Codeception\Test\Unit;
+
+/**
+ * Тесты конфигурации speed-test экшенов в WriteOffsErpController
+ *
+ * Покрывает:
+ * - Наличие actionSpeedTest и actionSpeedTestReport
+ * - CSRF отключён для speed-test и speed-test-report в beforeAction
+ * - AccessControl в web.php допускает speed-test без авторизации
+ * - Конфигурация UPLOAD_SPEED_TEST в params.php
+ * - Наличие JS-файла upload-speed-test.js
+ * - Наличие кнопки в create.php view
+ * - Миграция для таблицы upload_speed_tests
+ *
+ * @covers \app\controllers\WriteOffsErpController
+ * @group speed-test
+ */
+class WriteOffsErpSpeedTestConfigTest extends Unit
+{
+ private string $controllerPath;
+ private string $webConfigPath;
+ private string $paramsPath;
+ private string $jsPath;
+ private string $createViewPath;
+ private string $migrationPath;
+ private string $modelPath;
+
+ protected function _before(): void
+ {
+ $basePath = dirname(__DIR__, 3);
+ $this->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)'
+ );
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\UploadSpeedTest;
+
+/**
+ * Unit-тесты для AR-модели UploadSpeedTest
+ *
+ * Покрывает:
+ * - tableName()
+ * - rules() — валидация обязательных полей, типов, длин
+ * - attributeLabels()
+ * - Валидация корректных и некорректных данных
+ * - Значения по умолчанию
+ *
+ * @covers \yii_app\records\UploadSpeedTest
+ */
+class UploadSpeedTestTest extends Unit
+{
+ // ========================================================================
+ // Базовые тесты модели
+ // ========================================================================
+
+ /**
+ * Тест: tableName возвращает корректное имя таблицы
+ */
+ public function testTableNameReturnsCorrectName(): void
+ {
+ $this->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 = '<script>alert(1)</script>';
+
+ $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());
+ }
+}
$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]);
?>
<div class="write-offs-erp-create m-5">
<h1><?= Html::encode($this->title) ?></h1>
+ <div class="mb-3">
+ <button type="button" id="btn-speed-test" class="btn btn-outline-secondary btn-sm">
+ Тест скорости загрузки
+ </button>
+ <div id="speed-test-progress" style="display:none; max-width:400px;" class="mt-2">
+ <div class="progress" style="height:22px;">
+ <div id="speed-test-bar" class="progress-bar bg-primary progress-bar-striped progress-bar-animated"
+ role="progressbar" style="width:0%">0%</div>
+ </div>
+ <small id="speed-test-progress-text" class="text-muted"></small>
+ </div>
+ <div id="speed-test-result"></div>
+ </div>
+
<?php
if (!empty($errors)) {
- ?><span style="display: none"><?php
- print_r($errors);
- ?></span><span>Ошибка, обратитесь к администратору.</span><?php
+ ?><span style="display: none"><?= Html::encode(print_r($errors, true)) ?></span><span>Ошибка, обратитесь к администратору.</span><?php
}
?>
<?php
if (!empty($errors)) {
- ?><span style="display: none"><?php
- print_r($errors);
- ?></span><span>Ошибка, обратитесь к администратору.</span><?php
+ ?><span style="display: none"><?= Html::encode(print_r($errors, true)) ?></span><span>Ошибка, обратитесь к администратору.</span><?php
}
?>
/* 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);
+ });
+});
--- /dev/null
+(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 =
+ '<div class="card mt-3 border-' + verdict.css + '">' +
+ ' <div class="card-body">' +
+ ' <h5 class="card-title">' + verdict.icon + ' ' + verdict.text + '</h5>' +
+ ' <table class="table table-sm table-bordered mb-2" style="max-width:400px">' +
+ ' <tr><td>Скорость загрузки</td><td><strong>' + formatSpeed(speedMbps) + '</strong></td></tr>' +
+ ' <tr><td>Тестовый файл</td><td>' + TEST_SIZE_MB + ' МБ</td></tr>' +
+ ' <tr><td>Время загрузки</td><td>' + formatTime(elapsed) + '</td></tr>' +
+ ' <tr><td>1 фото (~3 МБ)</td><td>~' + estimateUpload(speedMbps, 3) + '</td></tr>' +
+ ' <tr><td>10 фото (~30 МБ)</td><td>~' + estimateUpload(speedMbps, 30) + '</td></tr>' +
+ ' <tr><td>Видео мин. (~160 МБ)</td><td>~' + estimateUpload(speedMbps, 160) + '</td></tr>' +
+ ' <tr><td>Видео макс. (~300 МБ)</td><td>~' + estimateUpload(speedMbps, 300) + '</td></tr>' +
+ ' </table>' +
+ ' <p class="mb-0 text-muted">' + verdict.detail + '</p>' +
+ ' </div>' +
+ '</div>';
+
+ 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 =
+ '<div class="alert alert-danger mt-3">' +
+ 'Ошибка сети. Не удалось выполнить тест. Проверьте интернет-соединение.' +
+ '</div>';
+ btn.disabled = false;
+ btn.textContent = 'Тест скорости загрузки';
+ });
+
+ xhr.addEventListener('timeout', function () {
+ container.innerHTML =
+ '<div class="alert alert-danger mt-3">' +
+ 'Таймаут. Сервер не ответил вовремя. Скорость загрузки очень низкая.' +
+ '</div>';
+ 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);
+ });
+ }
+ });
+})();
+/* 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 = ['Емельянова Ольга', 'Яшенкова Алёна', 'Ольга Цветкова'];
});
});
-$(".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();
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 () {
var strOut2 = messages.join(', <br>');
$('.messages').html(strOut2);
}
-});
\ No newline at end of file
+});
(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();
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
+})();
--- /dev/null
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Тест скорости загрузки файлов — ERP24</title>
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
+</head>
+<body>
+<div class="container mt-5">
+ <h1>Тест скорости загрузки файлов</h1>
+ <p class="text-muted">Страница для проверки скорости upload на сервер ERP24.</p>
+ <hr>
+
+ <div class="mb-3">
+ <button type="button" id="btn-speed-test" class="btn btn-primary btn-lg">
+ Запустить тест скорости
+ </button>
+ <div id="speed-test-progress" style="display:none; max-width:500px;" class="mt-3">
+ <div class="progress" style="height:24px;">
+ <div id="speed-test-bar" class="progress-bar bg-primary progress-bar-striped progress-bar-animated"
+ role="progressbar" style="width:0%">0%</div>
+ </div>
+ <small id="speed-test-progress-text" class="text-muted"></small>
+ </div>
+ <div id="speed-test-result"></div>
+ </div>
+</div>
+
+<script src="/js/upload-speed-test.js"></script>
+</body>
+</html>