# Инвентаризация backend и справочный gap-analysis с `tgFlaskForm` **Назначение.** §1–§9 — полная карта того, что **сейчас живёт** в `backend/` (Node.js / Express). Используется в [Этапе 1](migration-final.md#этап-1-текущий--единый-стек-express--flask-react--jinja-внутри-testingwebapp) как чек-лист «что переписать на Flask внутри `flask_app/`». §10 — **справочный gap-analysis** между этим и уже готовым модулем `cabinet/testing` в `HR_TG_Bot/tgFlaskForm` — пригодится в [Этапе 2](migration-to-tgflaskform.md) при слиянии. **Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-to-tgflaskform.md](migration-to-tgflaskform.md) (план Этапа 2 — слияние с tgFlaskForm), [PROJECT_STATUS.md](PROJECT_STATUS.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). > **Прочитайте сначала [migration-final.md](migration-final.md)** — там зафиксировано разделение на Этап 1 (унификация стека внутри TestingWebApp, БД остаётся `clinic_tests`) и Этап 2 (слияние с HR-кабинетом, БД мигрирует на `hr_bot_test`). Содержимое §10 этого документа — материал для Этапа 2. --- ## 1. Карта HTTP-эндпоинтов (всё, что зовёт фронтенд) Источник: `backend/src/app.js`, `backend/src/routes/*.js`. Перепроверено по фактическим вызовам из `frontend/src/**` (`frontend/src/api.js` — общий `fetch` с `credentials: 'include'`). | # | Метод | Путь | Зовёт из фронта | Auth | Сервис(ы) | Особенности | |---|------|------|----------------|------|----------|-------------| | 1 | GET | `/api/health` | — | нет | inline | smoke-проверка | | 2 | POST | `/api/auth/login` | `Login.jsx` | нет | `utils/auth`, `db/db`, `db/hrPool`, `utils/werkzeugPassword`, `utils/hrRoleMap`, `config/authConstants` | bcrypt (dev) **+** Werkzeug (HR) ветки; UPSERT `users` по `staff_id`; кладёт JWT в HTTP-only cookie | | 3 | POST | `/api/auth/logout` | `CabinetLayout.jsx` | нет | inline | очистить cookie | | 4 | GET | `/api/auth/me` | `CabinetLayout.jsx` | cookie JWT | `middleware/auth` + `featureFlags` | возвращает пользователя + флаги UI (`devUi`, `assignmentUi`) | | 5 | GET | `/api/auth/dev/assignment-directory` | `TestDetail.jsx` (assign-блок) | cookie JWT, feature-flag | `services/assignmentDirectoryService` | сливает HR (`staff_members`, `employees_departments`) + `clinic_tests.users`; query: `q`, `department`, `clinic` | | 6 | POST | `/api/tests/import/document` | `TestDetail.jsx` («Документ в вопросы») | cookie JWT | `services/documentExtractService` (PDF/DOCX/TXT), `services/documentGenService` → `services/llmClient` | `multer` (10 МБ, OS tmpdir); удаляет файл после извлечения | | 7 | GET | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testAccessService.queryTestsVisibleToUser` + inline (hiddenByYou) | каталог + список «скрытые мной» | | 8 | POST | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testDraftService.createTestWithVersion` | создаёт цепочку + версию 1 | | 9 | GET | `/api/tests/:id/summary` | `TestDetail.jsx` | cookie JWT | inline + `testAccessService.userHasTestAccess` | карточка цепочки (одна строка) | | 10 | GET | `/api/tests/:id/versions` | `TestDetail.jsx` | cookie JWT, **только автор** | inline + `testChainService.hasAnyAttemptForTest` | список версий + флаг `hasAttempts` | | 11 | GET | `/api/tests/:id/editor` | `TestDetail.jsx` | cookie JWT, **только автор** | `testAttemptService.getEditorContent` | вопросы активной версии **с правильными ответами** | | 12 | POST | `/api/tests/:id/ai/generate-test` | `TestDetail.jsx` (ИИ — целиком) | cookie JWT, **только автор** | `aiEditorService.generateFullTestByShape` → `llmClient` | строгая сетка `shape: [{optionsCount, hasMultipleAnswers}]`; до 40 вопросов | | 13 | POST | `/api/tests/:id/ai/generate-question` | `TestDetail.jsx` (ИИ — один вопрос) | cookie JWT, **только автор** | `aiEditorService.generateOrRephraseQuestion` → `llmClient` | пустой текст → новый вопрос; непустой → переформулировка | | 14 | POST | `/api/tests/:id/versions/:vid/activate` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | транзакция; снимает `is_active` со всех версий, потом ставит на `:vid` | | 15 | PATCH | `/api/tests/:id` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | `chainActive` (true/false) — публикация в каталоге | | 16 | POST | `/api/tests/:id/assign` | `TestDetail.jsx` (назначение) | cookie JWT, **только автор**, feature-flag | `assignmentUserService.ensureClinicUserIdForStaff` | принимает `userIds[]`/`staffIds[]`/legacy `userId`/`staffId`; одна строка `test_assignments` + N строк `test_assignment_targets` | | 17 | POST | `/api/tests/:id/draft` | `TestDetail.jsx` («Сохранить») | cookie JWT, **только автор** | `testDraftService.saveTestDraft` | если есть попытки и переданы `questions` — fork новой версии (V.3) | | 18 | POST | `/api/tests/:id/attempts/start` | `TestsList.jsx` | cookie JWT (доступ через `userHasTestAccess`) | `testAccessService.userHasTestAccess` + inline | новая попытка по активной версии | | 19 | GET | `/api/tests/:id/attempts` | `TestDetail.jsx` («Прохождения») | cookie JWT, **только автор** | `testAttemptService.listTestAttemptsForAuthor` | до 200 попыток по всем версиям | | 20 | GET | `/api/tests/:id/attempts/:aid/review` | `TestAttemptReview.jsx` | cookie JWT (владелец **или** автор) | `testAttemptService.getAttemptReviewForUser` → `buildReviewFromDb` | разбор попытки | | 21 | GET | `/api/tests/:id/attempts/:aid/play` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.getPlayContent` | вопросы **без** правильных ответов | | 22 | POST | `/api/tests/:id/attempts/:aid/submit` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.submitAttempt` | `FOR UPDATE`, проверка ответов, перезапись `user_answers`, статус `completed` | | 23 | GET | `/api/tests/:id/chain-info` | `TestDetail.jsx` | cookie JWT (через `userHasTestAccess`) | `testAccessService` + `testChainService` | флаг `hasAnyAttempt` | > **Итого: 22 функциональных эндпоинта** (без `/api/health`). Все ответы — JSON. Все входы — JSON или `multipart/form-data` (только `/import/document`). --- ## 2. Сервисный уровень (что должно появиться в Flask) | Файл (Node) | Что делает | Что нужно в Python | |------------|-----------|---------------------| | `services/testAccessService.js` | Запросы каталога; `userHasTestAccess(testId, userId)` | SQLAlchemy/`psycopg2` версия двух запросов | | `services/testDraftService.js` | `createTestWithVersion`, `saveTestDraft`, `forkNewVersion`, `replaceVersionContent`, `copyQuestionTree` | Транзакции на `psycopg2`/SQLAlchemy; следить за частичным уникальным индексом `uq_test_versions_one_active_per_test` | | `services/testAttemptService.js` | `getEditorContent`, `getPlayContent`, `submitAttempt`, `buildReviewFromDb`, `getAttemptReviewForUser`, `listTestAttemptsForAuthor`; вычисление баллов | Самый объёмный модуль; внимательно с массивами UUID (`user_answers.selected_options uuid[]`) | | `services/testChainService.js` | `hasAnyAttemptForTest` | Один SQL `EXISTS` | | `services/aiEditorService.js` | `parseAndValidateShape`, `generateFullTestByShape`, `generateOrRephraseQuestion`, `assertDraftMatchesShape` | Чистый Python; зависит от `llmClient` и `documentGenService.validate*` | | `services/documentGenService.js` | `parseJsonFromLlmText`, `validateAndNormalizeDraft`, `generationForImportDocument` | Чистый Python (`json.loads` + валидация формы) | | `services/documentExtractService.js` | `resolveDocumentKind`, `extractTextFromFile`, `extractTextFromBuffer` | **Замены пакетов:** `mammoth` → `python-docx` или `mammoth.py`; `pdf-parse` → `pypdf` (или `pdfminer.six`) | | `services/llmClient.js` | OpenAI-совместимый Chat Completions, JSON-mode, таймаут 120 с | `httpx` / `requests` + явный `timeout` | | `services/assignmentDirectoryService.js` | Слияние `clinic_tests.users` ↔ HR (`staff_members`, `employees_departments`); фильтры `q/department/clinic` | Два пула; `psycopg2` достаточно | | `services/assignmentUserService.js` | `ensureClinicUserIdForStaff` (UPSERT по `staff_id`) | UPSERT с `ON CONFLICT (staff_id)` | | `utils/auth.js` | bcrypt + JWT + (Werkzeug fallback) | `passlib[bcrypt]` или `bcrypt`; **`flask-jwt-extended`** или ручной `pyjwt` | | `utils/werkzeugPassword.js` | `scrypt:$N:$r:$p$salt$hex`, `pbkdf2:sha256:iter$salt$hex` | Уже **родной** Python: `werkzeug.security.check_password_hash` | | `utils/hrRoleMap.js` | строка HR-роли → `'hr'/'manager'/'employee'` | Один `def map_hr_role()` | | `middleware/auth.js` | `authenticate`, `requireRole`, `requireDepartment`, `optionalAuth` | Flask-декоратор `@login_required`/`@roles_required` (или `before_request`) | | `config/featureFlags.js` | `isAssignmentFeatureEnabled` | Простая функция от env | | `config/devAuthor.js` | `isTestAuthor(createdBy, userId)` | Один сравнительный helper | | `config/authConstants.js` | `HR_MANAGED_PASSWORD_PLACEHOLDER`, `isHrAuthEnabled` | Без изменений | | `messages/ru.js` | Текстовые сообщения API | `app/messages/ru.py` (dict) | > **Особое внимание:** `testAttemptService.submitAttempt` использует `FOR UPDATE` и выгружает все `answer_options` версии разом. Простую построчную проверку «правильно/нет» делает Python-функция `same_selection(set, set)`. На больших тестах помогает индекс `idx_answer_options_question_id` — он уже есть. --- ## 3. Базы данных и схемы ### 3.1 Основная — `clinic_tests` (`DATABASE_URL`) Из `backend/src/db/migrations/001_initial.sql`: - `departments` (UUID, name) - `users` (UUID, login UNIQUE, password_hash, full_name, role `user_role`, department_id FK, is_active, **`staff_id`** — миграция 003, **UNIQUE**) - `tests` (UUID, title, description, passing_threshold, time_limit, allow_back, is_active, is_versioned, created_by FK) - `test_versions` (UUID, test_id FK, version, is_active; миграция 002 добавляет `parent_id` + частичный уникальный индекс «одна активная версия на цепочку») - `questions` (UUID, test_version_id FK, text, question_order, has_multiple_answers) - `answer_options` (UUID, question_id FK, text, is_correct, option_order) - `test_assignments` (UUID, test_version_id FK, assigned_by FK, deadline DATE, max_attempts) - `test_assignment_targets` (UUID, assignment_id FK, target_type `'department'|'user'`, target_id UUID) - `test_attempts` (UUID, test_version_id FK, user_id FK, attempt_number, status `attempt_status`, started_at, completed_at, correct_count, total_questions, passed; UNIQUE(test_version_id, user_id, attempt_number)) - `user_answers` (UUID, attempt_id FK, question_id FK, selected_options **UUID[]**) - `migrations` (служебная, имена применённых SQL) **Расширения:** `uuid-ossp`. **Кастомные типы:** `user_role`, `target_type`, `attempt_status`. ### 3.2 Дополнительная — `hr_bot_test` (`HR_DATABASE_URL`, опционально) Используется только на чтение через `db/hrPool.js`: - `users` — для входа (HR_AUTH=1): `id`, `username`, `password_hash` (Werkzeug), `role` - `staff_members` — `id`, `fio`, `web_login` - `employees_departments` — `staff_id`, `department` ### 3.3 Миграции 3 SQL-файла в `backend/src/db/migrations/`. Простой `npm run migrate` пишет в служебную таблицу `migrations`. В Flask эквивалент: **Alembic** или ручной runner на `psycopg2`. Файлы `.sql` можно переиспользовать **как есть** — никакого Node-специфичного синтаксиса в них нет. --- ## 4. Зависимости Node → Python (план замены) | Node-пакет | Python-замена | Комментарий | |-----------|----------------|-------------| | `express`, `cors`, `cookie-parser` | **Flask** + `flask-cors` | сессии встроены | | `multer` | `Flask` (`request.files`) + `werkzeug.utils.secure_filename` + `tempfile` | лимит 10 МБ — `MAX_CONTENT_LENGTH` | | `dotenv` | `python-dotenv` (уже есть в `flask_app/run.py`) | — | | `pg` | `psycopg2-binary` (или `psycopg[binary]` v3) | — | | `bcryptjs` | `bcrypt` (`pip install bcrypt`) | формат `$2b$…` совместим | | `jsonwebtoken` | `pyjwt` или `flask-jwt-extended` | важен **тот же** алгоритм/секрет, чтобы старые cookie работали в переходный период | | `mammoth` | `mammoth` (PyPI: `pip install mammoth`) или `python-docx` | API почти идентичен | | `pdf-parse` | `pypdf` (`pip install pypdf`) или `pdfminer.six` | `pypdf.PdfReader().pages[].extract_text()` | | `fetch` (LLM) | `httpx` (рекомендую — есть `timeout`, async) или `requests` | сохранить `Authorization: Bearer …`, `response_format: json_object` | **Уже в `tgFlaskForm` есть готовое** — `werkzeug.security.check_password_hash` (бесплатно), Sentry, Jinja2-шаблоны, обмен с `staff_members`. Переиспользовать, если выберем сценарий «общий кабинет». --- ## 5. Переменные окружения (полный список) | Переменная | Где читается | Назначение | Обязательная | |-----------|--------------|-----------|--------------| | `NODE_ENV` | `app.js`, `auth.js`, `featureFlags.js`, `db.js` | dev vs production | да (нет → dev) | | `PORT` | `server.js` | Express, по умолчанию 3001 | нет | | `FRONTEND_URL` | `app.js` (CORS) | разрешённый origin в prod | в prod — да | | `DATABASE_URL` **или** `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASSWORD` | `db/poolConfig.js` | основная БД `clinic_tests` | да | | `DB_POOL_MAX`, `DB_IDLE_TIMEOUT`, `DB_CONNECTION_TIMEOUT` | `db/poolConfig.js` | пул | нет | | `HR_DATABASE_URL` | `db/poolConfig.js` (`getHrPoolConfig`) | hr_bot_test (read-only) | при HR_AUTH=1 — да | | `HR_DB_POOL_MAX` | то же | пул HR | нет | | `HR_AUTH` (1/true) | `config/authConstants.js` | вход по HR-логину | нет | | `JWT_SECRET` | `utils/auth.js` | подпись JWT | **да** | | `JWT_EXPIRES_IN` | `utils/auth.js` | дефолт `7d` | нет | | `DEEPSEEK_API_KEY` | `services/llmClient.js` | LLM, приоритет №1 | для AI/импорта — да | | `OPENAI_API_KEY` | то же | LLM, приоритет №2 | альтернатива | | `LLM_BASE_URL` | то же | сменить хост (proxy / vLLM) | нет | | `LLM_MODEL` | то же | по умолчанию `deepseek-chat` или `gpt-4o-mini` | нет | | `LLM_NO_JSON` (1) | то же | отключить `response_format: json_object` (для моделей без поддержки) | нет | | `CLINIC_ASSIGNMENT_ENABLED` (1) | `config/featureFlags.js` | прод: включить assign | в prod — да | В `flask_app/.env` нужно перенести **те же ключи** (имена можно сохранить, чтобы не плодить вариации). --- ## 6. Что вызывает фронтенд (карта зависимостей React → API) Используется один тонкий клиент `frontend/src/api.js`: `fetch` с `credentials: 'include'`, базовый путь — пустой (т.е. **`/api/...` относительно текущего origin**, что в dev резолвит Vite-proxy, а в prod — Nginx). Это значит: - **Менять фронтенд при смене бэкенда не нужно**, если новый сервис отвечает по тем же путям. - В dev сейчас `vite.config.js` проксирует `/api` на Express (`localhost:3001`). После переноса — заменить адрес/порт на Flask (см. `flask_app/run.py`, по умолчанию `3107`). Список путей (отсортирован по убыванию использований): ``` /api/auth/login /api/auth/logout /api/auth/me /api/auth/dev/assignment-directory?q&department&clinic /api/tests /api/tests/:id/summary /api/tests/:id/versions /api/tests/:id/editor /api/tests/:id/chain-info /api/tests/:id/draft /api/tests/:id/assign /api/tests/:id/ai/generate-test /api/tests/:id/ai/generate-question /api/tests/:id (PATCH) /api/tests/:id/versions/:vid/activate /api/tests/:id/attempts /api/tests/:id/attempts/start /api/tests/:id/attempts/:aid/play /api/tests/:id/attempts/:aid/review /api/tests/:id/attempts/:aid/submit /api/tests/import/document ``` --- ## 7. Тесты, которые тянутся за собой В `backend/src/**/*.test.js` (Node test runner): - `apiSmoke.test.js` — smoke-проверки HTTP. - `services/testChainService.test.js` - `services/aiEditorService.test.js` - `services/documentGenService.test.js` - `services/documentExtractService.test.js` - `utils/werkzeugPassword.test.js` - `integration/v9card1.test.js` (требует `CLINIC_TESTS_INTEGRATION=1`) После переноса нужны их Python-аналоги (`pytest`). Часть (валидация LLM-формы, разбор Werkzeug) тривиально превращается в юниты. --- ## 8. Чего сейчас в `flask_app/` НЕТ (чтобы не повторяться в Спринте 2) `flask_app/` содержит только: `run.py`, `app/__init__.py` (с `/health` и `/`), `app/templates/`, `app/static/`. Не реализовано **ничего** из перечня §1–§4. Это значит, что в Спринте 2 нужно с нуля поднять: 1. Подключение к БД (минимум — основной пул `clinic_tests`). 2. Декоратор аутентификации (cookie JWT) и эндпоинты `/api/auth/*`. 3. Роуты `/api/tests/*` поэтапно — начать с **read-only** (list, summary, versions, editor, attempts, review), потом write (draft, activate, patch, assign, attempts/start/submit), потом AI/import. 4. CORS (в dev совпадает с тем, что у Express). 5. Запуск под Vite/Nginx — обновить proxy/upstream. --- ## 9. Критерии «можно удалять `backend/`» (Спринт 4) - ETL-скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` прогнан **на копии прод**, агрегаты совпадают (тесты, версии, вопросы, попытки) — затем прогон на проде в окне. - Все сценарии §1 (TestingWebApp) либо имеют **рабочий аналог** в `cabinet/testing/`, либо явно **признаны не-MVP** (см. §10). - Внутренние ссылки и закладки сотрудников переключены на URL общего кабинета (`/cabinet/testing/...`). - Документация (`README.md`, `DEV_CONTOUR_USER_GUIDE.md`, `шаги/*`) перестала ссылаться на `backend/` и `frontend/` TestingWebApp. - Резерв: ветка `legacy/clinic-tests-node` (или `legacy/express-backend`) зафиксирована перед удалением. --- ## 10. Gap-analysis: `tgFlaskForm/cabinet/testing` vs Express Сверка проведена по фактическому коду `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/*` и `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` на 2026-04-27. Колонка «Что нужно сделать» — это и есть остаток Спринта 2 из [migration-to-tgflaskform.md](migration-to-tgflaskform.md). ### 10.1 Маппинг сущностей и ID | TestingWebApp (`clinic_tests`) | tgFlaskForm (`hr_bot_test`, схема `testing_*`) | Замечание | |---|---|---| | `tests` (UUID) | `testing_tests` (Integer PK) | UUID → integer; маппинг хранится в `_clinic_tests_migration_map` | | `test_versions` (UUID, `version`, `is_active`, `parent_id`) | `testing_test_versions` (Integer, `version_number`, `is_active_version`) — **+** `passing_score_percent`, `time_limit_minutes`, `allow_back_navigation` (нормализовано на версию) | В Express часть полей лежала на `tests` (`passing_threshold`, `time_limit`, `allow_back`); ETL поднимает их на версию | | `questions` (`text`, `has_multiple_answers`, `question_order`) | `testing_questions` (`question_text`, `question_type` `'single'\|'multiple'`, `sort_order`) | `has_multiple_answers: true` ↔ `question_type='multiple'` | | `answer_options` (`text`, `is_correct`, `option_order`) | `testing_answers` (`answer_text`, `is_correct`, `sort_order`) | прямой маппинг | | `users` + `users.staff_id` | `staff_members.id` напрямую | Express держит «свою» строку `users` со ссылкой на `staff_id`; в HR — только `staff_members` | | `test_assignments` + `test_assignment_targets` (target_type='user', target_id=UUID) | `testing_assignments` (одна строка = одна пара тест×сотрудник, `assigned_to=staff_id`) | **Существенное расхождение модели** — см. §10.3 | | `test_attempts` (привязан к `test_version_id`, `user_id`) | `testing_attempts` (привязан к `assignment_id` + `test_version_id`, попытка от **сотрудника**) | Если в clinic попытка не имеет связанного assignment — ETL создаёт синтетическое назначение (`max_attempts=99`) | | `user_answers` (`selected_options uuid[]`) | `testing_attempt_answers` (одна строка на каждый выбранный `answer_id`) | Развёртка массива в N строк | | (нет аналога) | `testing_settings` (key-value), `testing_head_positions` (право назначения по должности) | в Express не было — RBAC проще | ### 10.2 Эндпоинты Express → роуты `tgFlaskForm` | # | Express | Соответствие в `tgFlaskForm` | Статус | Что сделать | |---|---------|------------------------------|--------|-------------| | 1 | `POST /api/auth/login` | `webApp/auth/*` (общая сессия HR) | ✅ есть аналог | Express-сессии не переносим; пользователи входят как обычно в HR-кабинет | | 2 | `POST /api/auth/logout` | `webApp/auth/*` | ✅ — | — | | 3 | `GET /api/auth/me` | session + `helpers._get_staff_id` | ✅ — | — | | 4 | `GET /api/auth/dev/assignment-directory` | `routes_assignments.assign_form` (страница) + `testing_get_employees_for_assignment` | ✅ — | На UI: добавить «Выбрать всех» (см. Спринт 3 React-кабинета), мульти-фильтры | | 5 | `GET /api/health` | `webApp/__init__.py` | ✅ — | — | | 6 | `GET /api/tests` (каталог + `hiddenByYou`) | `routes_tests.test_list` (по автору) + `routes_passing.my_tests` (по назначениям) | ⚠️ нет «скрытые мной» отдельным разделом | Добавить вкладку/фильтр «скрытые автором» в `cabinet/testing/test_list.html` | | 7 | `POST /api/tests` (создать пустой) | `routes_tests.test_create` (требует full payload + ≥7 вопросов) | ⚠️ другой контракт | Решить продуктово: оставить ограничение `≥7` либо разрешить «пустой тест» как в Express. **Рекомендую** разрешить пустой и валидировать только при публикации | | 8 | `GET /api/tests/:id/summary` | вшито в `test_edit` | ⚠️ нет отдельной summary | Добавить функцию `testing_get_test_summary(test_id)` если нужен лёгкий вариант для списка | | 9 | `GET /api/tests/:id/versions` | `testing_get_test_for_edit` отдаёт активную; история — внутри `test_edit` | ⚠️ — | Вынести список версий в API/шаблон («История» в UX) | | 10 | `GET /api/tests/:id/editor` | `routes_tests.test_edit` + `testing_get_test_for_edit` | ✅ — | сверить набор полей, отдаваемых шаблону, с тем что показывает React-редактор | | 11 | `POST /api/tests/:id/ai/generate-test` (строгая `shape: [{optionsCount, hasMultipleAnswers}]`, до 40 вопросов) | `routes_ai.ai_generate` принимает `topic, question_count` (фикс. форма) | ❌ **gap** | Добавить вариант с явной формой (передавать массив `optionsCount/hasMultipleAnswers`); либо принять, что HR-кабинет генерирует «обобщённо» | | 12 | `POST /api/tests/:id/ai/generate-question` (один вопрос: пусто=новый, текст=переформулировать) | `routes_ai.ai_improve` (улучшить) + `ai_distractors` (варианты) | ❌ **gap** | Добавить ручку «один вопрос со строгой сеткой» (объединить `improve` + `distractors` под единый сценарий) | | 13 | `POST /api/tests/:id/versions/:vid/activate` | `routes_tests.test_version_activate` | ✅ — | — | | 14 | `PATCH /api/tests/:id` (`chainActive`) | `routes_tests.test_toggle` | ✅ — | сверить семантику (toggle vs явный флаг) | | 15 | `POST /api/tests/:id/assign` (массивы `userIds`/`staffIds`) | `routes_assignments.assign_submit` (`employee_ids = staff_id[]`) | ✅ совместимо | — | | 16 | `POST /api/tests/:id/draft` (с авто-fork версии) | `routes_tests.test_update` (тоже фаркает при `has_attempts`) | ⚠️ контракт другой | Сверить, что в HR-кабинете тоже работает «правка без попыток = на месте, после попыток = новая версия». В коде есть, но нужна проверка | | 17 | `POST /api/tests/:id/attempts/start` | `routes_passing.start_attempt(assignment_id)` | ⚠️ другой ключ | Привести к виду «start by test_id» **или** оставить как есть и в UI стартовать по `assignment_id` (логичнее) | | 18 | `GET /api/tests/:id/attempts` | `routes_results.tracker` (общая лента) — без фильтра по тесту | ⚠️ нет per-test ленты | Добавить фильтр `test_id` или отдельный URL `/cabinet/testing/tests/:id/attempts` | | 19 | `GET /api/tests/:id/attempts/:aid/review` | `routes_results.result(attempt_id)` | ✅ — | сверить, что разбор показывает выбранные/верные варианты по каждому вопросу | | 20 | `GET /api/tests/:id/attempts/:aid/play` | `routes_passing.take_test(attempt_id)` | ✅ — | — | | 21 | `POST /api/tests/:id/attempts/:aid/submit` | `routes_passing.finish_attempt(attempt_id)` (+ `save_answer`) | ⚠️ контракт другой | Express отправляет все ответы пакетом; HR — пошагово (`save_answer` × N + `finish`). Это **OK для UX** (продолжить позже), но фронт работает иначе | | 22 | `POST /api/tests/import/document` | `routes_import.import_document` | ✅ — | сверить mime-типы и сообщения об ошибках | | 23 | `GET /api/tests/:id/chain-info` (`hasAnyAttempt`) | вшито в `testing_get_test_for_edit['has_attempts']` | ✅ — | — | > **Итог по gap-analysis:** 13 из 22 эндпоинтов закрыты «как есть» или близко; 4 требуют добавления (AI «один вопрос», AI «строгая сетка», per-test attempts, summary); 5 — расхождение контракта, надо принять решение или подровнять. ### 10.3 Расхождения, которые ETL уже обходит - **Назначения на отдел:** в `clinic_tests.test_assignment_targets` могут быть строки `target_type='department'`. ETL разворачивает их в N строк `testing_assignments` по списку сотрудников отдела на момент миграции (см. шапку `tools/migrate_clinic_tests_to_hr.py`). - **Попытки без явного назначения:** в Express `test_attempts.user_id` напрямую идёт от пользователя; в HR попытка обязательно имеет `assignment_id`. ETL для каждой пары (тест, сотрудник) создаёт **синтетическое** назначение `max_attempts=99` и привязывает попытку к нему. - **UUID → Integer:** идемпотентный маппинг лежит в `public._clinic_tests_migration_map` (entity, old_uuid → new_id) — повторный прогон ETL безопасен. ### 10.4 Чего точно нет в `tgFlaskForm` и потребует доработки 1. **AI «один вопрос со строгой сеткой»** (`generate_or_rephrase_question` из `aiEditorService.js`). 2. **AI «целый тест по shape»** с проверкой `optionsCount` и `hasMultipleAnswers` для каждой строки. 3. **«Скрытые автором» цепочки** как отдельный список на UI. 4. **`/tests/:id/summary`** — лёгкая ручка для карточки в списке (если потребуется в новом UI). 5. **Per-test лента попыток** (UI «Прохождения» в аккордеоне «История»). 6. **Мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md):** аккордеоны «О тесте / Вопросы / История / Показ в каталоге», фикс-футер, икон-кнопка удаления варианта, «Выбрать всех» в назначении и т.д. В Этапе 1 это переносится в Jinja-шаблоны `flask_app/app/templates/`. В Этапе 2 уже готовые шаблоны переезжают в `tgFlaskForm/webApp/templates/cabinet/testing/`. ### 10.5 Что точно НЕ переносим - **Свой JWT/cookie/bcrypt** из `backend/src/utils/auth.js` и `middleware/auth.js`. Авторизация — через сессии общего кабинета. - **Werkzeug-полифилл в Node** (`utils/werkzeugPassword.js`). В Python это родная функция `werkzeug.security.check_password_hash`. - **Свои миграции SQL** из `backend/src/db/migrations/`. Целевая схема живёт в `tgFlaskForm/db/models.py` и сопутствующих DDL/Alembic. - **`flask_app/`** в этом репозитории — каркас уже **не нужен**, в Спринте 4 решим: удалить или оставить как историческую заготовку. - **`backend/src/**/*.test.js`** (Node test runner). Их предметная нагрузка покрывается **либо** уже существующими тестами `tgFlaskForm`, **либо** новыми pytest-юнитами.