From 8423af5dd55ddd5136a86ed71874ed5fe4f72947 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 10 Aug 2026 16:00:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA,=20=D0=BF=D0=BE=D0=B4=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB,=20PDF,=20/versions=20(#2824)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/deploy-tradein.yml | 48 +++++++ tradein-mvp/CHANGELOG.md | 46 +++++++ tradein-mvp/VERSION | 1 + tradein-mvp/backend/Dockerfile | 24 ++++ tradein-mvp/backend/app/api/v1/version.py | 20 +++ tradein-mvp/backend/app/core/rbac.py | 4 + tradein-mvp/backend/app/core/version.py | 83 ++++++++++++ tradein-mvp/backend/app/main.py | 2 + .../app/services/exporters/trade_in_pdf.py | 36 ++++-- tradein-mvp/backend/tests/test_version_api.py | 99 +++++++++++++++ tradein-mvp/frontend/Dockerfile | 30 +++++ tradein-mvp/frontend/src/app/v2/layout.tsx | 10 ++ .../frontend/src/app/versions/page.tsx | 120 ++++++++++++++++++ .../src/components/trade-in/VersionFooter.tsx | 80 ++++++++++++ .../src/components/trade-in/v2/Footer.tsx | 17 +-- .../src/components/trade-in/v2/TopNav.tsx | 89 +++++++++---- .../src/components/trade-in/v2/ui-config.ts | 8 +- tradein-mvp/frontend/src/lib/buildInfo.ts | 37 ++++++ tradein-mvp/frontend/src/lib/changelog.ts | 106 ++++++++++++++++ tradein-mvp/frontend/src/types/version.ts | 34 +++++ 20 files changed, 845 insertions(+), 49 deletions(-) create mode 100644 tradein-mvp/CHANGELOG.md create mode 100644 tradein-mvp/VERSION create mode 100644 tradein-mvp/backend/app/api/v1/version.py create mode 100644 tradein-mvp/backend/app/core/version.py create mode 100644 tradein-mvp/backend/tests/test_version_api.py create mode 100644 tradein-mvp/frontend/src/app/versions/page.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx create mode 100644 tradein-mvp/frontend/src/lib/buildInfo.ts create mode 100644 tradein-mvp/frontend/src/lib/changelog.ts create mode 100644 tradein-mvp/frontend/src/types/version.ts diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index ee062b18..d45dd831 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -30,11 +30,26 @@ jobs: infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} # Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE # в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend. + app_version: ${{ steps.build-meta.outputs.app_version }} + build_sha: ${{ steps.build-meta.outputs.build_sha }} + build_date: ${{ steps.build-meta.outputs.build_date }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник + # правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки — + # проброшены как build-args в build-backend/build-frontend ниже (см. + # tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile). + # Считается ОДИН раз здесь, а не в каждой job отдельно. + - name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE) + id: build-meta + run: | + echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT" + echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # Resolve base SHA: read last-successfully-deployed SHA from the VPS host file. # The file is written by the deploy job on every successful deploy. # Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD, @@ -107,8 +122,20 @@ jobs: # scheduler_main импортирует пакет) — kit-only изменение обязано # пересобрать образ, иначе деплой рестартует контейнеры на старом. - 'tradein-mvp/packages/scraper-kit/**' + # APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend + # ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ + # правок кода обязан пересобрать образ, иначе GET /version и колонтитул + # PDF продолжат отдавать старое значение при формально «успешном» деплое. + - 'tradein-mvp/VERSION' frontend: - 'tradein-mvp/frontend/**' + # NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же + # причина, что у backend выше. + - 'tradein-mvp/VERSION' + # /versions статически запекает CHANGELOG.md в билд (см. + # frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ + # frontend/** иначе не долетала бы до образа. + - 'tradein-mvp/CHANGELOG.md' browser: - 'tradein-mvp/browser/**' infra: @@ -211,6 +238,13 @@ jobs: context: ./tradein-mvp file: ./tradein-mvp/backend/Dockerfile push: true + # APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см. + # backend/Dockerfile ARG→ENV) — читает app/core/version.py: + # GET /api/v1/trade-in/version + колонтитул PDF-отчёта. + build-args: | + APP_VERSION=${{ needs.changes.outputs.app_version }} + BUILD_SHA=${{ needs.changes.outputs.build_sha }} + BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | @@ -236,6 +270,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context + # (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста, + # поэтому копируем внутрь ДО build. /versions статически запекает его + # содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage + # комментарий). Не влияет на кэш другого шага — читается только этим. + - name: Stage CHANGELOG.md into frontend build context + run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md + - name: Build & push tradein-frontend uses: docker/build-push-action@v6 with: @@ -246,9 +288,15 @@ jobs: # (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в # dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница # уходит в notFound (404), не индексируется и не краулится. + # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js + # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит, + # см. frontend/Dockerfile комментарий у соответствующих ARG). build-args: | NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in + NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} + NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} + NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | diff --git a/tradein-mvp/CHANGELOG.md b/tradein-mvp/CHANGELOG.md new file mode 100644 index 00000000..0e70377d --- /dev/null +++ b/tradein-mvp/CHANGELOG.md @@ -0,0 +1,46 @@ +# История версий «МЕРА» + +Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и +[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно +`## ` (машинно читается страницей истории версий). + +## 2.1.0 — 2026-08-10 + +Первая версия с явным версионированием. Номер продолжает ряд, который до этого +показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние +отчёты. + +### Добавлено + +- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и + реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень + уверенности в оценке. +- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав + аналогов и сделок, формирование выкупной стоимости. +- Аналитика по дому — история размещений объявлений и продаж в доме. +- История прошлых оценок в личном кабинете, автодополнение адреса при поиске. +- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история). +- Чат поддержки на сайте, в том числе без входа в личный кабинет. +- Публичный лендинг «МЕРА». +- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта + страница истории версий. + +### Изменено + +- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более + раннего технического макета. + +### Исправлено + +- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе + комнатности не было варианта «Студия», из-за чего для студии подбирались + однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась. +- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор + автоматически расширяется (студии, срок объявлений, новостройки, радиус), + а над результатом показывается предупреждение о сниженной точности и о том, + какие параметры пришлось расширить. +- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части + адресов улица не распознавалась, и разделы оставались пустыми. +- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов. +- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки + на обложке, некорректные допущения о сроке экспозиции. diff --git a/tradein-mvp/VERSION b/tradein-mvp/VERSION new file mode 100644 index 00000000..7ec1d6db --- /dev/null +++ b/tradein-mvp/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index 4958161c..24d65162 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages COPY --from=builder --chown=app:app /app/backend/app /app/app COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts +# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего +# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION). +# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста. +COPY --chown=app:app VERSION VERSION + +# Версия продукта + короткий git SHA + дата сборки — запечены как build-args +# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend). +# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам +# (VERSION-файл выше / "dev" / момент импорта модуля). +# +# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых +# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на +# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои +# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале +# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get + +# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот +# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже). +ARG APP_VERSION="" +ARG BUILD_SHA="" +ARG BUILD_DATE="" +ENV APP_VERSION=$APP_VERSION \ + BUILD_SHA=$BUILD_SHA \ + BUILD_DATE=$BUILD_DATE + USER app # HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые diff --git a/tradein-mvp/backend/app/api/v1/version.py b/tradein-mvp/backend/app/api/v1/version.py new file mode 100644 index 00000000..86729727 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/version.py @@ -0,0 +1,20 @@ +"""GET /api/v1/trade-in/version — build metadata (product version + short SHA + +build date), source `app/core/version.py`. + +Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не +секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только +process env / уже загруженные при импорте константы (без похода в БД).""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA + +router = APIRouter() + + +@router.get("/version") +def get_version() -> dict[str, str]: + """{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}.""" + return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE} diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index e8c5fc04..0dd654ff 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset( "/api/v1/trade-in/support/anon/messages", "/api/v1/trade-in/support/anon/unread", "/api/v1/trade-in/support/anon/read", + # Версионирование (VERSION-файл + build-args, см. app/core/version.py): + # не секрет, читает только process env — быстрая справка для клиента/ + # поддержки/смоук-теста, не должна требовать сессию. + "/api/v1/trade-in/version", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/app/core/version.py b/tradein-mvp/backend/app/core/version.py new file mode 100644 index 00000000..44101a3a --- /dev/null +++ b/tradein-mvp/backend/app/core/version.py @@ -0,0 +1,83 @@ +"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`. + +`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env, +запечённые в образ через build-args в `backend/Dockerfile` +(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же +ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и +`date -u +%Y-%m-%dT%H:%M:%SZ`. + +Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env — +тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов, +см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта +модуля. Ничего здесь не должно падать при отсутствии env (потребитель — +и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`). + +Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда. +Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION` +в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и +`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля / +`@/lib/buildInfo` соответственно; не заводи третий. +""" + +from __future__ import annotations + +import datetime as dt +import os +from pathlib import Path + +_DEFAULT_VERSION = "0.0.0" +# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает +# и локальный layout (backend/app/core/version.py → ../../../VERSION == +# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/ +# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION). +_MAX_ANCESTORS = 6 + + +def _find_version_file() -> Path | None: + here = Path(__file__).resolve() + for ancestor in list(here.parents)[:_MAX_ANCESTORS]: + candidate = ancestor / "VERSION" + if candidate.is_file(): + return candidate + return None + + +def _read_version_file() -> str: + path = _find_version_file() + if path is None: + return _DEFAULT_VERSION + try: + text = path.read_text(encoding="utf-8").strip() + except OSError: + return _DEFAULT_VERSION + return text or _DEFAULT_VERSION + + +def _default_build_date() -> str: + return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# Читаются один раз при импорте модуля (совпадает с паттерном `settings = +# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем, +# перечитывать на каждый запрос незачем. +APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file() +BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev" +BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date() + + +def format_build_date_human(build_date: str = BUILD_DATE) -> str: + """ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF + колонтитул). Никогда не бросает исключение — при неразборчивой строке + возвращает её как есть (это футер отчёта, не API-контракт).""" + try: + parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return build_date + return parsed.strftime("%d.%m.%Y") + + +def product_version_line(product_name: str = "Мера") -> str: + """`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта + 2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF + колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`.""" + return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 7e99bbf5..347cad8c 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -34,6 +34,7 @@ from app.api.v1 import ( support, team, trade_in, + version, ) from app.core.auth_db import get_auth_engine from app.core.config import settings @@ -216,6 +217,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) +app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 676d8f71..a89eadf6 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price from matplotlib.patches import Rectangle from app.core.config import settings +from app.core.version import product_version_line from app.schemas.trade_in import AggregatedEstimate, AnalogLot logger = logging.getLogger(__name__) @@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом) _BORDER = _LINE _BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline) -# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит -# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не -# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь -# HUD, а не версия PDF-модуля/API. -_REPORT_ENGINE_VERSION = "v2.0.6" - # Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/ # 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах. _FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions @@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: "ДАТА", report_date.strftime("%d.%m.%Y") ) + # Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение + # владельца продукта 2026-08-10, см. app/core/version.py::product_version_line. + # Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска + # реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера», + # одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под + # существующим masthead-рядом — не растёт по высоте ни при каком контенте + # (клипается по ширине, не переносится), top-margin (25mm) даёт под неё + # запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты. + version_html = ( + f'
' + f"{_html.escape(product_version_line())}
" + ) + return ( + f"
" f'
' + f'padding-bottom:6pt;margin-bottom:3pt;">' f"{mark_html}" f'{meta_html}' f"
" + f"{version_html}" + f"
" ) @@ -529,7 +542,11 @@ def _page_footer( строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ - хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта. + хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь + НЕ дублируется — единственное место вывода версии в PDF — running-header + (_page_header → product_version_line()); раньше рядом с wordmark висел + decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной + версией продукта — расходился с header на каждой странице, снесён. page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер на офер-странице), которого нет в веб-референсе (там нет пагинации). Не @@ -587,9 +604,6 @@ def _page_footer( font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2}; min-width:0;overflow-wrap:anywhere;"> {_html.escape(brand.name).upper()} - - {_REPORT_ENGINE_VERSION} diff --git a/tradein-mvp/backend/tests/test_version_api.py b/tradein-mvp/backend/tests/test_version_api.py new file mode 100644 index 00000000..767e6b87 --- /dev/null +++ b/tradein-mvp/backend/tests/test_version_api.py @@ -0,0 +1,99 @@ +"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py + +app/api/v1/version.py. + +Isolated FastAPI app (no full app.main import, no DB) — same pattern as +tests/test_geocode_reverse_api.py: mount only the router under test. +""" + +from __future__ import annotations + +import importlib +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import version as version_module +from app.core import version as version_core + + +@pytest.fixture +def app() -> FastAPI: + application = FastAPI() + application.include_router(version_module.router, prefix="/api/v1/trade-in") + return application + + +# ── GET /api/v1/trade-in/version ───────────────────────────────────────────── + + +def test_version_endpoint_shape(app: FastAPI) -> None: + client = TestClient(app) + r = client.get("/api/v1/trade-in/version") + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {"version", "sha", "built_at"} + assert isinstance(body["version"], str) and body["version"] + assert isinstance(body["sha"], str) and body["sha"] + assert isinstance(body["built_at"], str) and body["built_at"] + + +def test_version_endpoint_matches_core_constants(app: FastAPI) -> None: + client = TestClient(app) + body = client.get("/api/v1/trade-in/version").json() + assert body["version"] == version_core.APP_VERSION + assert body["sha"] == version_core.BUILD_SHA + assert body["built_at"] == version_core.BUILD_DATE + + +def test_version_path_is_public_no_auth_required() -> None: + """rbac_guard must let this path through without X-Authenticated-User / + session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call.""" + from app.core.rbac import _PUBLIC_PATHS + + assert "/api/v1/trade-in/version" in _PUBLIC_PATHS + + +# ── app/core/version.py — product_version_line / format_build_date_human ──── + + +def test_product_version_line_format() -> None: + line = version_core.product_version_line("Мера") + assert line.startswith("Мера v") + parts = line.split(" · ") + assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}" + + +def test_format_build_date_human_parses_iso_utc() -> None: + assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026" + + +def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None: + assert version_core.format_build_date_human("not-a-date") == "not-a-date" + + +# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ────── +# (local `uvicorn` run without a Docker build — see module docstring in +# app/core/version.py). Reloading the module re-executes its module-level +# env reads; nothing here may raise. + + +def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_VERSION", raising=False) + monkeypatch.delenv("BUILD_SHA", raising=False) + monkeypatch.delenv("BUILD_DATE", raising=False) + + reloaded = importlib.reload(version_core) + + assert reloaded.BUILD_SHA == "dev" + assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default + assert reloaded.BUILD_DATE.endswith("Z") + # format/product helpers must still work off the fallback values (no crash). + assert reloaded.product_version_line("Мера").startswith("Мера v") + + # Reload once more so any test running later in this process sees a module + # state consistent with whatever env pytest was actually invoked under. + importlib.reload(version_core) diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index 9abbd35a..5def62b7 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -30,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_ENABLE_PREVIEW="" ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW +# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть +# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические +# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не +# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник +# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml, +# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для +# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже +# frontend-код, потребляющий эти env (Dockerfile сам файл не читает). +ARG NEXT_PUBLIC_APP_VERSION="" +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +ARG NEXT_PUBLIC_BUILD_SHA="" +ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA +ARG NEXT_PUBLIC_BUILD_DATE="" +ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE + +# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на +# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI +# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно +# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job +# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с +# остальным контекстом. Локальная сборка без этого шага CI просто не находит +# файл — readChangelog() уже умеет деградировать (пустая история), сам +# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже). COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build @@ -49,6 +72,13 @@ ENV NODE_ENV=production \ COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static +# /versions — Server Component, statically prerendered at `npm run build` +# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked +# into .next/standalone above. This is a defensive fallback ONLY, in case that +# page ever stops being static: glob (trailing `*`) makes it a no-op when the +# builder stage doesn't have the file either (local build without the CI +# pre-copy step, see builder stage comment above) — never fails the build. +COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./ USER node EXPOSE 3000 diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx index bf603195..c7d0543a 100644 --- a/tradein-mvp/frontend/src/app/v2/layout.tsx +++ b/tradein-mvp/frontend/src/app/v2/layout.tsx @@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google"; import { SupportButton } from "@/components/trade-in/v2/SupportButton"; import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext"; import { pageBg } from "@/components/trade-in/v2/tokens"; +import { VersionFooter } from "@/components/trade-in/VersionFooter"; // Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled // (no package.json change). Cyrillic + latin so RU labels render correctly. @@ -53,6 +54,15 @@ export default function TradeInV2Layout({ products without the МЕРА brand that don't need a support link. */} + {/* Real build-version indicator (task: показать реальную версию + продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider — + it needs no chat context — but still scoped to this /v2 layout for + the same reason SupportButton is: other basePath routes + (/scrapers/**, /sale-share) are unrelated products without the + МЕРА brand. Portals to document.body itself (see VersionFooter.tsx + docstring), so its position in this tree only matters for mount + order, not DOM placement. */} + ); } diff --git a/tradein-mvp/frontend/src/app/versions/page.tsx b/tradein-mvp/frontend/src/app/versions/page.tsx new file mode 100644 index 00000000..7c82a2ab --- /dev/null +++ b/tradein-mvp/frontend/src/app/versions/page.tsx @@ -0,0 +1,120 @@ +// /versions (→ `/trade-in/versions` behind basePath) — «История версий». +// +// Server Component, deliberately NOT "use client": `readChangelog()` reads +// `tradein-mvp/CHANGELOG.md` off disk via `fs.readFileSync` at build/render +// time and gets statically embedded — no client-side fetch, no network hop +// (see src/lib/changelog.ts for the exact read/parse contract + a known +// build-context gap, flagged there). +// +// Auth: this route has NO guard of its own — it lives inside the same +// app-router segment as every other closed МЕРА page (history/, cache/, +// team/), so `app/layout.tsx`'s `` already gates it exactly +// like the rest of the product. No new RBAC path was added; whatever the +// backend `auth/roles.yaml` wildcard already allows for `/trade-in/**` +// covers this page too. +import type { Metadata } from "next"; +import Link from "next/link"; + +import "@/components/trade-in/trade-in.css"; +import { APP_VERSION, formatRuDate } from "@/lib/buildInfo"; +import { readChangelog } from "@/lib/changelog"; + +export const metadata: Metadata = { + title: "История версий — МЕРА", +}; + +export default function VersionsPage() { + const entries = readChangelog(); + + return ( +
+

+ ← К оценке +

+ +

+ История версий +

+

+ Текущая версия:{" "} + {APP_VERSION === "dev" ? "dev-сборка" : `v${APP_VERSION}`} +

+ + {entries.length === 0 ? ( +

+ История изменений пока не опубликована. +

+ ) : ( +
+ {entries.map((entry) => { + const isCurrent = entry.version === APP_VERSION; + return ( +
+
+

+ v{entry.version} + {isCurrent && ( + + текущая + + )} +

+
{formatRuDate(entry.date)}
+
+
+ {entry.sections.length === 0 ? ( +

+ Без описания изменений. +

+ ) : ( + entry.sections.map((section) => ( +
+

+ {section.title} +

+
    + {section.items.map((item, i) => ( +
  • + {item} +
  • + ))} +
+
+ )) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx new file mode 100644 index 00000000..426e46df --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx @@ -0,0 +1,80 @@ +"use client"; + +// VersionFooter — small build-version indicator for the МЕРА product, +// showing the REAL deployed version. This is the SINGLE place on /trade-in/v2 +// that renders a version number — `v2/TopNav.tsx` and `v2/Footer.tsx` used to +// each carry their own hardcoded "v2.0.6" literal (`./ui-config`'s `version`) +// next to the МЕРА wordmark; both were removed (three independent "versions" +// on one screen, see PR review) — the wordmark stays in both places, just +// without a number attached. Values here come from build-time +// `NEXT_PUBLIC_*` env vars via `@/lib/buildInfo` — no runtime API call, no +// useEffect fetch. +// +// Mounted in `app/v2/layout.tsx` (not `app/v2/page.tsx` — that file is +// off-limits for this change), right next to ``. +// +// Portaled to document.body — same reasoning/pattern as SupportButton.tsx: +// /v2 renders its HUD inside a fixed-size "artboard" that gets +// `transform: scale(...)` on narrow viewports (app/v2/page.tsx), and a +// `position: fixed` descendant of a transformed ancestor is positioned +// relative to THAT ancestor, not the real viewport corner — portaling +// sidesteps that entirely, exactly like the support button already does. +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { tokens } from "@/components/trade-in/v2/tokens"; +import { formatVersionLabel } from "@/lib/buildInfo"; + +const styles = ` +.version-footer{opacity:.72;transition:opacity .15s;} +.version-footer:hover{opacity:1;} +.version-footer a{color:${tokens.muted2};text-decoration:underline;text-underline-offset:2px;} +.version-footer a:hover{color:${tokens.ink};} +@media (max-width: 480px){ + .version-footer{left:10px !important;bottom:10px !important;padding:3px 7px !important;font-size:9px !important;gap:6px !important;} +} +`; + +export function VersionFooter() { + // Portal-mount guard (SSR-safe): `document` only exists after mount + // (mirrors SupportButton.tsx / MapPicker.tsx). + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted) return null; + + return createPortal( + <> + +
+ {formatVersionLabel()} + История версий +
+ , + document.body, + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index 9dab743a..3b04d0b7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -1,10 +1,13 @@ // Report footer for the /trade-in/v2 "МЕРА Оценка" design port. // Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439): // report id / date / valid-until on the left, a decorative centre line, and the -// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`. +// МЕРА wordmark on the right. Static markup, id/date/validUntil via `data`. +// The trailing "v2.0.6" badge that used to sit next to the wordmark was a +// hardcoded literal (./ui-config `version`), independent of the real deployed +// build — removed. The real version is shown once, by `` +// (see app/v2/layout.tsx), not duplicated here. import { tokens } from "./tokens"; -import { version } from "./ui-config"; import type { Report } from "./types"; interface FooterProps { @@ -122,16 +125,6 @@ export function Footer({ data, hasEstimate }: FooterProps) { > МЕРА - - {version} - ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 9bf5a19d..70674679 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -2,10 +2,13 @@ // Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port. // Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90): -// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu. +// inline SVG logo + 5 nav tabs (active underline/triangle) + user menu. // Tabs change only local UI state via onNavigate; the user dropdown owns its -// own useState. No data fetching — labels/version come from ./ui-config, the user +// own useState. No data fetching — labels come from ./ui-config, the user // identity is fed in from the page (real useMe), colours from tokens. +// The build-version badge that used to sit next to the logo (hardcoded +// "v2.0.6") was removed — the real deployed version is shown once, by +// `` (app/v2/layout.tsx), not duplicated here. import { useState } from "react"; import type { CSSProperties } from "react"; @@ -13,7 +16,7 @@ import type { CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; import { tokens } from "./tokens"; -import { navLabels, version } from "./ui-config"; +import { navLabels } from "./ui-config"; import { useSupportChat } from "./SupportChatContext"; // Real logged-in user identity, derived by the page from useMe() @@ -70,15 +73,29 @@ const menuItemStyle: CSSProperties = { tokens.muted), что и остальные иконки этого дропдауна. */ function UsersIcon() { return ( -
-
- {version} -
{/* Nav tabs */} @@ -412,7 +417,13 @@ export default function TopNav({ aria-disabled="true" title="Раздел «Профиль» скоро появится" > -
- -