]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
[erp-233] Добавление логирования на загрузку файлов в документ списания.
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Mon, 16 Feb 2026 21:34:45 +0000 (00:34 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Mon, 16 Feb 2026 21:34:45 +0000 (00:34 +0300)
16 files changed:
docker/php/dev.php.env
erp24/config/params.php
erp24/config/web.php
erp24/controllers/WriteOffsErpController.php
erp24/migrations/m260215_200000_create_upload_speed_tests_table.php [new file with mode: 0644]
erp24/records/UploadSpeedTest.php [new file with mode: 0644]
erp24/services/UploadService.php
erp24/tests/unit/controllers/WriteOffsErpSpeedTestConfigTest.php [new file with mode: 0644]
erp24/tests/unit/records/UploadSpeedTestTest.php [new file with mode: 0644]
erp24/views/write_offs_erp/create.php
erp24/views/write_offs_erp/update.php
erp24/web/js/heic_to_jpg_replace.js
erp24/web/js/upload-speed-test.js [new file with mode: 0644]
erp24/web/js/validate/validateForm.js
erp24/web/js/write-offs-erp/update.js
erp24/web/speed-test.html [new file with mode: 0644]

index d45239a9250b7537fa74f2b715a89fea46417327..54d115cd709787d1029345b9bf8b960170dd9978 100644 (file)
@@ -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
index e80cf20e6563141e97c7e5d40a2d2e6b9f72a4d2..bd903337bf40c48a0f2e8552f6ae20655be2c163 100644 (file)
@@ -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] Тест скорости загрузки файлов',
+    ],
 ];
index 2353269bf74bfb966903be43dba46b2c972de622..726100556dde163628b200f74f33240a7dd61b2e 100644 (file)
@@ -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'],
                 ],
             ],
index 0e3dd672b039d13aa6d87b43afbd0f73e3d1d504..836a2893e5467a930316cc4f5412c025e99d1026 100644 (file)
@@ -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 (file)
index 0000000..dc97542
--- /dev/null
@@ -0,0 +1,61 @@
+<?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);
+    }
+}
diff --git a/erp24/records/UploadSpeedTest.php b/erp24/records/UploadSpeedTest.php
new file mode 100644 (file)
index 0000000..9fef670
--- /dev/null
@@ -0,0 +1,92 @@
+<?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']);
+    }
+}
index f125c86b3cadf40304eed25c1d9317cd079f4262..40614fcafdf6130bc4378d2d33df012fa4de1748 100644 (file)
@@ -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 (file)
index 0000000..815a92f
--- /dev/null
@@ -0,0 +1,606 @@
+<?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)'
+        );
+    }
+}
diff --git a/erp24/tests/unit/records/UploadSpeedTestTest.php b/erp24/tests/unit/records/UploadSpeedTestTest.php
new file mode 100644 (file)
index 0000000..082ec49
--- /dev/null
@@ -0,0 +1,565 @@
+<?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());
+    }
+}
index 87493e3c50244575a533031180af5f5e1a699087..355f7effd86b600ab2d3f0882a97bda470e64c1f 100644 (file)
@@ -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]);
 ?>
 <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
     }
     ?>
 
index cbdce38251fc95e1104bd99094d6fd4065ca63f5..42e57d80583c8ae017c2a294d4d1da5118498be6 100644 (file)
@@ -44,9 +44,7 @@ $this->registerJsFile('/js/write-offs-erp/update.js', ['position' => \yii\web\Vi
 
     <?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
     }
     ?>
 
index 3da5ec524904957f6fe036f99f1d1a8982a0b240..927ff7e65c0d8a9e7cd9309e052fe0c2145fca81 100644 (file)
@@ -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 (file)
index 0000000..1832d37
--- /dev/null
@@ -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 =
+                '<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);
+            });
+        }
+    });
+})();
index 288bbc98d1db0540c02eff3036a56173a7c6ec89..63c642d2888a41c5d3f0275ff581418ded8f80fc 100755 (executable)
@@ -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(', <br>');
         $('.messages').html(strOut2);
     }
-});
\ No newline at end of file
+});
index 3cb1e6f5322d457417c06cc0f5ff644c2f3c6f65..305a67122a27b0e8b12606621e074076c04b1ad5 100644 (file)
@@ -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();
             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 (file)
index 0000000..49effb1
--- /dev/null
@@ -0,0 +1,32 @@
+<!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>