Compare commits
7 commits
5eeb12cff8
...
bf55b4b80e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf55b4b80e | ||
|
|
ddbaa10ada | ||
| f04728297b | |||
| 10626c21ae | |||
| 88a3ce1875 | |||
| 6609d7a4cb | |||
|
|
4a1b57f758 |
10 changed files with 659 additions and 40 deletions
|
|
@ -38,42 +38,58 @@ Read-only tech-analyst в autonomous-pickup mode. Создаёшь Forgejo issue
|
|||
3. THROTTLE check:
|
||||
- GET issues?labels=status/ready (ready-queue size K)
|
||||
- Если K ≥ 10 → result: ready queue full (K), skipping decomposition
|
||||
4. CODE ARCHEOLOGY ⚠️ MANDATORY (канал к worker'у = ТОЛЬКО текст issue → делай его жирным):
|
||||
- Grep по ключевым словам в backend/app/ или frontend/src/ → найди РЕАЛЬНЫЕ файлы/функции
|
||||
- Read точечно эти файлы → выпиши конкретные пути + сигнатуры, которые worker будет менять
|
||||
- Для БД-задачи — Read data/sql/NN_*.sql + schemas-MOC в vault (DDL-набросок)
|
||||
Worker НЕ имеет Opus-оркестратора и НЕ читает vault — он строит код из issue. Тонкий issue =
|
||||
worker флоудит. Спецификация (Files/сигнатуры) — ТВОЯ ответственность, не worker'а.
|
||||
5. DECOMPOSE:
|
||||
- Берёшь unprocessed item из inbox/feedback
|
||||
- Разбиваешь на 1-3 sub-issues, dependency-ordered
|
||||
- Каждый sub-issue: single scope (никаких cross-domain), estimate S/M/L
|
||||
6. CREATE через Forgejo API — body в ЖИРНОМ tech-analyst-формате (Files/сигнатуры/Acceptance/Risk):
|
||||
POST /repos/<repo>/issues, body =
|
||||
4. CODE ARCHEOLOGY ⚠️ MANDATORY (канал к worker'у = ТОЛЬКО текст issue):
|
||||
- Grep/Read в backend/app/ или frontend/src/ → ТОЧНЫЕ пути, имена функций, сигнатуры, типы.
|
||||
- БД-задача → Read data/sql/NN_*.sql + schemas-MOC → точные таблицы/колонки/типы.
|
||||
- Выписывай РЕАЛЬНЫЕ идентификаторы, НЕ плейсхолдеры. Worker строит код только из issue,
|
||||
без Opus-оркестратора и без vault. Тонкий/расплывчатый issue = broken/флоуд PR.
|
||||
5. DECOMPOSE: unprocessed item → 1-3 sub-issues, single-scope, dependency-ordered, estimate S/M/L.
|
||||
6. NO-AMBIGUITY GATE ⚠️ (перед CREATE — перечитай issue ГЛАЗАМИ worker'а с нулевым контекстом):
|
||||
- Все пути / имена / типы — ТОЧНЫЕ из archeology, без плейсхолдеров (`<area>`, «соответствующий
|
||||
сервис», «нужный файл»).
|
||||
- Каждый Definition-of-Done пункт — БИНАРНО проверяем: команда → ожидаемый результат
|
||||
(не «работает корректно», не «выглядит ок»).
|
||||
- Любой шаг толкуется ≥2 способами → доуточни до ЕДИНСТВЕННОГО толкования ИЛИ +needs-human.
|
||||
НЕ постить `status/ready` с двусмысленностью.
|
||||
- Числа конкретны: «<500ms p95» не «быстро»; имя+тип колонки не «поле».
|
||||
7. CREATE (`mcp__forgejo__create_issue`) — body = ИСПОЛНЯЕМЫЙ work-prompt (не описание):
|
||||
"""
|
||||
> Worker: это исполняемый спек. Делай ровно то, что ниже. Неясность/конфликт с кодом →
|
||||
> коммент в issue, НЕ угадывай.
|
||||
|
||||
## Задача
|
||||
<императив, 1 предложение: что именно сделать>
|
||||
|
||||
## Контекст
|
||||
<2-3 предложения: зачем, что выяснил из code archeology>
|
||||
<2-3 предложения: зачем + факты из code archeology>
|
||||
|
||||
## Files (что менять — из archeology, конкретные пути)
|
||||
- `backend/app/services/<area>.py:NNN` — <функция / что добавить>
|
||||
- `data/sql/NN_<topic>.sql` (новый) — <DDL-набросок>
|
||||
## Files (точные пути из archeology)
|
||||
- `backend/app/api/v1/parcels.py:128` — добавить handler `get_poi_score`
|
||||
- `data/sql/96_poi_score_idx.sql` (новый) — индекс на `cad_parcels(parcel_id)`
|
||||
|
||||
## Сигнатуры / API contract (если применимо)
|
||||
- `def foo(parcel_id: int, *, opts: ...) -> Result` ИЛИ endpoint shape (method/path/response)
|
||||
## Сигнатуры / контракт (точные, не «похожие»)
|
||||
- `async def get_poi_score(parcel_id: int, db: Session = Depends(get_db)) -> PoiScoreOut`
|
||||
- Response 200: `{parcel_id:int, poi_score:float, computed_at:str}`; 404 если parcel нет
|
||||
|
||||
## Acceptance
|
||||
- [ ] <проверяемый критерий, 2-5 пунктов>
|
||||
## Definition of Done (бинарно проверяемо)
|
||||
- [ ] `curl -s .../api/v1/parcels/123/poi-score` → 200 + поля parcel_id/poi_score/computed_at
|
||||
- [ ] `uv run pytest backend/tests/test_poi_score.py` → pass
|
||||
- [ ] `uv run ruff check <изменённые файлы>` → clean
|
||||
|
||||
## Не делать (out of scope)
|
||||
- НЕ менять scoring-логику в `scorer.py` (только expose существующего поля)
|
||||
- НЕ трогать frontend
|
||||
|
||||
## Risk
|
||||
- <что может пойти не так / что НЕ сломать>
|
||||
- `parcels.py` — hot-file: не ломай существующие routes
|
||||
|
||||
## Depends on
|
||||
- #N (если есть; frontend-issue → status/blocked пока backend не done)
|
||||
"""
|
||||
labels: ["scope/X", "status/ready" | "status/blocked", "priority/pN"]
|
||||
estimate S(<2h)/M(2-8h)/L(>8h — ещё дроби) — в первом comment
|
||||
7. UPDATE inbox-файла — добавить frontmatter `forgejo_issue: #N` для де-дупа
|
||||
8. result: created N issues (ids: #X #Y #Z) from inbox/<file>
|
||||
estimate S(<2h)/M(2-8h)/L(>8h — ещё дроби) — первым comment (`mcp__forgejo__create_issue_comment`)
|
||||
8. UPDATE inbox-файла — frontmatter `forgejo_issue: #N` для де-дупа
|
||||
9. result: created N issues (ids: #X #Y #Z) from inbox/<file>
|
||||
```
|
||||
|
||||
## Decomposition rules
|
||||
|
|
@ -90,9 +106,13 @@ Read-only tech-analyst в autonomous-pickup mode. Создаёшь Forgejo issue
|
|||
- ❌ Создавать issue без `scope/*` и `status/*` — workers не подхватят
|
||||
- ❌ Decomposition если ready queue ≥ 10 (flooding prevention)
|
||||
- ❌ Trigger self — этот файл не должен быть spawned через Task tool
|
||||
- ❌ Создавать issue с unclear acceptance — workers застрянут
|
||||
- ❌ Создавать issue без секций **Files** + (если есть) **сигнатуры** из реальной code archeology —
|
||||
- ❌ Issue без секций **Задача** + **Files** + **Definition of Done** (+ **сигнатуры** если код) —
|
||||
worker строит код только из issue, тонкий spec = broken/флоуд PR
|
||||
- ❌ **Плейсхолдеры / расплывчатость** в posted issue (`<area>`, «соответствующий сервис», «нужный
|
||||
endpoint», «быстро») — только точные идентификаторы из archeology
|
||||
- ❌ **Не-бинарный Definition of Done** («работает корректно») — каждый пункт = команда + ожидаемый результат
|
||||
- ❌ Постить `status/ready`, не пройдя **NO-AMBIGUITY GATE** (шаг 6) — двусмысленность → доуточни или +needs-human
|
||||
- ✅ Один issue = единственное толкование. Перечитай глазами worker'а с нулевым контекстом перед CREATE
|
||||
|
||||
## Idle behavior
|
||||
|
||||
|
|
|
|||
|
|
@ -49,14 +49,19 @@ Write-Host "✓ PAT belongs to $($me.login) — analyst persona ready"
|
|||
1. Kill-switch check (label `pause-bots` на repo)
|
||||
2. Read новые commits + vault inbox + closed-since-last-tick Forgejo issues
|
||||
3. Throttle: если queue `status/ready` ≥ 10 → skip decomposition
|
||||
4. Decompose work-items на 1-3 sub-issues, single-scope per issue
|
||||
5. POST issues с labels `scope/X status/ready priority/pN`
|
||||
6. Update vault inbox-file с frontmatter `forgejo_issue: #N`
|
||||
4. **Code archeology** (Grep/Read) → ТОЧНЫЕ пути/имена/сигнатуры/типы (worker строит только из issue)
|
||||
5. Decompose на 1-3 sub-issues, single-scope, dependency-ordered
|
||||
6. **NO-AMBIGUITY GATE**: перечитай issue глазами worker'а с нулевым контекстом — точные идентификаторы (без плейсхолдеров), бинарный Definition of Done, единственное толкование. Иначе доуточни / +needs-human, НЕ постить ready
|
||||
7. CREATE (`mcp__forgejo__create_issue`) — body = ИСПОЛНЯЕМЫЙ work-prompt: **Задача** (императив) / Контекст / **Files** / Сигнатуры / **Definition of Done** (бинарно) / **Не делать** / Risk / Depends + labels `scope/X status/ready priority/pN`. Полный шаблон — `auto-analyst.md` шаг 7
|
||||
8. Update vault inbox-file: frontmatter `forgejo_issue: #N`
|
||||
|
||||
**Что НЕ делаю:**
|
||||
|
||||
- ❌ НЕ пишу код (read-only role)
|
||||
- ❌ НЕ создаю issues без `scope/*` и `status/*`
|
||||
- ❌ НЕ создаю issues без `scope/*` и `status/*`, без **Задача/Files/Definition of Done**
|
||||
- ❌ НЕ плейсхолдеры/расплывчатость (`<area>`, «соответствующий сервис», «быстро») — только точные идентификаторы из archeology
|
||||
- ❌ НЕ не-бинарный Definition of Done («работает корректно») — каждый пункт = команда + ожидаемый результат
|
||||
- ❌ НЕ постить ready с двусмысленностью (≥2 толкований) — доуточни или +needs-human
|
||||
- ❌ НЕ flooding — stop при ready queue ≥ 10
|
||||
- ❌ НЕ trigger себя через Task tool
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@ def _assert_estimate_access(created_by: str | None, x_authenticated_user: str |
|
|||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
|
||||
|
||||
def _assert_estimate_access_by_id(
|
||||
db: Session, estimate_id: UUID, x_authenticated_user: str | None
|
||||
) -> None:
|
||||
"""IDOR guard для derived-роутов, не читающих саму оценку (#690).
|
||||
|
||||
Тянет created_by оценки и применяет _assert_estimate_access. 404 если оценки
|
||||
нет (как и owner-mismatch — существование не подтверждаем).
|
||||
"""
|
||||
row = db.execute(
|
||||
text("SELECT created_by FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(estimate_id)},
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||
|
||||
|
||||
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||
async def estimate(
|
||||
payload: TradeInEstimateInput,
|
||||
|
|
@ -339,14 +356,16 @@ async def upload_photo(
|
|||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
file: Annotated[UploadFile, File()],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> PhotoMeta:
|
||||
"""Загрузить фото квартиры к оценке (#394). Хранение в estimate_photos (bytea)."""
|
||||
estimate = db.execute(
|
||||
text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
text("SELECT created_by FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(estimate_id)},
|
||||
).fetchone()
|
||||
if estimate is None:
|
||||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
_assert_estimate_access(estimate.created_by, x_authenticated_user)
|
||||
|
||||
ctype = (file.content_type or "").lower()
|
||||
if ctype not in _ALLOWED_IMAGE_TYPES:
|
||||
|
|
@ -413,8 +432,10 @@ async def upload_photo(
|
|||
def list_photos(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> list[PhotoMeta]:
|
||||
"""Список фото оценки — метаданные, без содержимого (#394)."""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -438,8 +459,10 @@ def get_photo(
|
|||
estimate_id: UUID,
|
||||
photo_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> Response:
|
||||
"""Отдать содержимое фото — image bytes (#394)."""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -566,6 +589,7 @@ _HOUSE_SELECT_COLS = """
|
|||
def get_estimate_houses(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> list[HouseInfoForEstimate]:
|
||||
"""House(s) информация для estimate.
|
||||
|
||||
|
|
@ -576,6 +600,7 @@ def get_estimate_houses(
|
|||
Возвращаем прямой матч + nearby (dedup by id), up to ~6 домов.
|
||||
Пустой список если нет matches.
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
target = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -653,12 +678,14 @@ def get_estimate_houses(
|
|||
def get_estimate_placement_history(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> list[PlacementHistoryEntry]:
|
||||
"""Историческая продажная активность по дому(ам) target estimate.
|
||||
|
||||
Возвращает rows из house_placement_history для всех houses связанных с
|
||||
target адресом. Сортировано по last_price_date DESC.
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
target = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -735,12 +762,14 @@ def get_estimate_placement_history(
|
|||
def get_estimate_house_analytics(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> HouseAnalyticsResponse:
|
||||
"""House-level analytics from house_placement_history backfill.
|
||||
|
||||
Resolves target house(s) — если в самом доме <8 hist rows — расширяем поиск до 300м.
|
||||
Возвращает: price-history by year (median ₽/м²), recent sold (12mo), KPI.
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
target = db.execute(
|
||||
text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(estimate_id)},
|
||||
|
|
@ -924,6 +953,7 @@ def get_estimate_house_analytics(
|
|||
def get_estimate_cian_price_changes(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> list[CianPriceChangeStats]:
|
||||
"""История изменений цены для Cian-аналогов из estimate.
|
||||
|
||||
|
|
@ -936,6 +966,7 @@ def get_estimate_cian_price_changes(
|
|||
Возвращает только аналоги с хотя бы одним изменением цены.
|
||||
Пустой список если нет cian-аналогов или нет истории.
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -1013,6 +1044,7 @@ def get_estimate_cian_price_changes(
|
|||
def get_estimate_sell_time_sensitivity(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> SellTimeSensitivityResponse:
|
||||
"""Срок продажи в зависимости от цены к медиане дома/района.
|
||||
|
||||
|
|
@ -1020,6 +1052,7 @@ def get_estimate_sell_time_sensitivity(
|
|||
Filter last_price > start_price * 0.7 — отбрасываем подозрительно
|
||||
заниженные лоты (выбросы, ошибки парсинга).
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
# 1. Resolve house_ids (same logic as house-analytics)
|
||||
target = db.execute(
|
||||
text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
|
|
@ -1195,6 +1228,7 @@ def get_estimate_sell_time_sensitivity(
|
|||
def get_estimate_imv_benchmark(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> IMVBenchmarkResponse:
|
||||
"""Avito IMV benchmark для estimate (для UI badge «наша 6.4М · Avito 6.29М»).
|
||||
|
||||
|
|
@ -1202,6 +1236,7 @@ def get_estimate_imv_benchmark(
|
|||
1. avito_imv_evaluations WHERE estimate_id = :id (если linked в estimator)
|
||||
2. Если не linked — fallback: most recent IMV для same address (TTL 24h)
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
# Сначала пытаемся найти directly linked
|
||||
row = db.execute(
|
||||
text(
|
||||
|
|
|
|||
|
|
@ -1645,11 +1645,18 @@ async def estimate_quality(
|
|||
# больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое
|
||||
# поведение. listing_segment для Tier C берём из самого частого среди аналогов.
|
||||
anchor_tier: str | None = None
|
||||
# #691: гейт НЕ требует радиусных аналогов (listings_clean) / median_price>0.
|
||||
# На проде геокод часто = None → ST_DWithin не находит радиусные комплы →
|
||||
# median=0, и same-building якорь скипался, ХОТЯ комплы того же дома есть
|
||||
# (_fetch_anchor_comps резолвит дом по payload.address без гео — Ткачёва 13,
|
||||
# Сакко 99). Запускаем по resolved-таргету (area + raw-адрес/geo); если комплов
|
||||
# реально нет — _compute_same_building_anchor вернёт None и мутации не будет
|
||||
# (поведение идентично старому). Новый гейт ⊇ старого: при непустом radius
|
||||
# адрес всегда есть, так что ранее-якорившиеся кейсы якорятся по-прежнему.
|
||||
if (
|
||||
settings.estimate_same_building_anchor_enabled
|
||||
and listings_clean
|
||||
and median_price > 0
|
||||
and payload.area_m2
|
||||
and (payload.address or (geo is not None and geo.full_address))
|
||||
):
|
||||
seg_counts: dict[str, int] = {}
|
||||
for lot in listings_clean:
|
||||
|
|
|
|||
|
|
@ -254,3 +254,223 @@ def test_pdf_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
|||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_pdf_unknown_user_gets_403(trade_in_app: FastAPI) -> None:
|
||||
"""/pdf: authenticated via Caddy but missing from roles.yaml → 403 (reviewer gap)."""
|
||||
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))
|
||||
client = _client_with(trade_in_app, db_mock, role=None)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf",
|
||||
headers={"X-Authenticated-User": "ghost"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ── Reviewer-named gap: legacy NULL created_by ───────────────────────────────
|
||||
|
||||
|
||||
def test_get_estimate_legacy_null_owner_non_admin_gets_404(trade_in_app: FastAPI) -> None:
|
||||
"""Legacy estimate with NULL created_by: non-admin pilot must NOT read it → 404."""
|
||||
db_mock = _make_db_mock(_make_estimate_row(created_by=None))
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Photo endpoints (#690 — raw apartment-interior bytes are enumerable PII) ──
|
||||
|
||||
|
||||
def _make_photo_row() -> SimpleNamespace:
|
||||
"""A minimal estimate_photos row for get_photo (content + content_type)."""
|
||||
return SimpleNamespace(content=b"\x89PNG-fake", content_type="image/png")
|
||||
|
||||
|
||||
def _make_db_mock_seq(*rows: SimpleNamespace | None) -> MagicMock:
|
||||
"""DB session mock whose successive .execute(...).fetchone() yield *rows* in order.
|
||||
|
||||
Derived/photo routes run the guard SELECT (created_by) first, then their own
|
||||
queries; configure side_effect so the guard row comes first.
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(*_a, **_k):
|
||||
result = MagicMock()
|
||||
result.fetchone.return_value = next(_iter)
|
||||
return result
|
||||
|
||||
_iter = iter(rows)
|
||||
db.execute.side_effect = _execute
|
||||
return db
|
||||
|
||||
|
||||
def test_get_photo_owner_can_read(trade_in_app: FastAPI) -> None:
|
||||
"""Owner pilot reads photo bytes → 200."""
|
||||
db_mock = _make_db_mock_seq(
|
||||
SimpleNamespace(created_by="kopylov"), # guard query
|
||||
_make_photo_row(), # route query
|
||||
)
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"\x89PNG-fake"
|
||||
|
||||
|
||||
def test_get_photo_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
||||
"""Non-owner pilot must NOT read someone else's photo → 404 (guard raises first)."""
|
||||
db_mock = _make_db_mock_seq(SimpleNamespace(created_by="victim"))
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "attacker"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_photo_admin_can_read_any(trade_in_app: FastAPI) -> None:
|
||||
"""Admin reads any photo → 200."""
|
||||
db_mock = _make_db_mock_seq(
|
||||
SimpleNamespace(created_by="someone_else"),
|
||||
_make_photo_row(),
|
||||
)
|
||||
client = _client_with(trade_in_app, db_mock, role="admin")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_get_photo_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
||||
"""get_photo: no X-Authenticated-User header → 401."""
|
||||
db_mock = _make_db_mock_seq(SimpleNamespace(created_by="kopylov"))
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos/{_ESTIMATE_ID}",
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_list_photos_owner_can_read(trade_in_app: FastAPI) -> None:
|
||||
"""Owner pilot lists photos → 200 (empty list ok)."""
|
||||
db = MagicMock()
|
||||
guard_result = MagicMock()
|
||||
guard_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
|
||||
list_result = MagicMock()
|
||||
list_result.mappings.return_value.all.return_value = []
|
||||
db.execute.side_effect = [guard_result, list_result]
|
||||
client = _client_with(trade_in_app, db, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_list_photos_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
||||
"""Non-owner pilot must NOT list someone else's photos → 404."""
|
||||
db_mock = _make_db_mock_seq(SimpleNamespace(created_by="victim"))
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos",
|
||||
headers={"X-Authenticated-User": "attacker"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_upload_photo_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
||||
"""Write-side IDOR: non-owner pilot must NOT upload to someone else's estimate → 404.
|
||||
|
||||
Guard runs on the existence SELECT (created_by) before any write — so a single
|
||||
fetchone returning created_by='admin' + a non-owner header is enough.
|
||||
"""
|
||||
db_mock = _make_db_mock_seq(SimpleNamespace(created_by="admin"))
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.post(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos",
|
||||
headers={"X-Authenticated-User": "attacker"},
|
||||
files={"file": ("x.png", b"\x89PNG-fake", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_upload_photo_owner_can_upload(trade_in_app: FastAPI, monkeypatch) -> None:
|
||||
"""Owner pilot uploads → 200. Stub sanitize_image + the INSERT mapping row."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
trade_in_module, "sanitize_image", lambda _c: (b"\x89PNG-clean", "image/png")
|
||||
)
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
db = MagicMock()
|
||||
guard_result = MagicMock()
|
||||
guard_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
|
||||
count_result = MagicMock()
|
||||
count_result.scalar_one.return_value = 0
|
||||
insert_result = MagicMock()
|
||||
insert_result.mappings.return_value.fetchone.return_value = {
|
||||
"id": _ESTIMATE_ID,
|
||||
"filename": "x.png",
|
||||
"content_type": "image/png",
|
||||
"size_bytes": 9,
|
||||
"uploaded_at": datetime.now(tz=UTC),
|
||||
}
|
||||
db.execute.side_effect = [guard_result, count_result, insert_result]
|
||||
client = _client_with(trade_in_app, db, role="pilot")
|
||||
resp = client.post(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/photos",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
files={"file": ("x.png", b"\x89PNG-fake", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Derived analytics routes (representative: /houses, /imv-benchmark) ────────
|
||||
|
||||
|
||||
def test_get_estimate_houses_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
||||
"""Derived route: non-owner pilot blocked by guard → 404 (before any house query)."""
|
||||
db_mock = _make_db_mock_seq(SimpleNamespace(created_by="victim"))
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/houses",
|
||||
headers={"X-Authenticated-User": "attacker"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_estimate_houses_owner_can_read(trade_in_app: FastAPI) -> None:
|
||||
"""Derived route: owner pilot passes guard → 200. Stub target with no address/geo."""
|
||||
db = MagicMock()
|
||||
guard_result = MagicMock()
|
||||
guard_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
|
||||
target_result = MagicMock()
|
||||
target_result.fetchone.return_value = SimpleNamespace(lat=None, lon=None, address=None)
|
||||
db.execute.side_effect = [guard_result, target_result]
|
||||
client = _client_with(trade_in_app, db, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/houses",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
||||
"""imv-benchmark: non-owner pilot blocked by guard → 404."""
|
||||
db_mock = _make_db_mock_seq(SimpleNamespace(created_by="victim"))
|
||||
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/imv-benchmark",
|
||||
headers={"X-Authenticated-User": "attacker"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
|
|
|||
|
|
@ -377,13 +377,20 @@ def _run_estimate(
|
|||
flag_enabled: bool = True,
|
||||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||
payload=None,
|
||||
radius_analogs: list[dict[str, Any]] | None = None,
|
||||
):
|
||||
"""estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован."""
|
||||
"""estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован.
|
||||
|
||||
radius_analogs=[] моделирует прод-сценарий #691: геокод дал lat/lon, но
|
||||
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
||||
по same-building комплам независимо.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
pl = payload or _make_payload()
|
||||
radius = list(_RADIUS_ANALOGS if radius_analogs is None else radius_analogs)
|
||||
|
||||
async def _run():
|
||||
with (
|
||||
|
|
@ -394,7 +401,7 @@ def _run_estimate(
|
|||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(_RADIUS_ANALOGS), False, "W"),
|
||||
return_value=(radius, False, "W"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
|
||||
|
|
@ -434,6 +441,30 @@ def test_estimate_premium_lift_real_in_range() -> None:
|
|||
assert est.range_high_rub >= int(real * 0.9)
|
||||
|
||||
|
||||
def test_estimate_anchor_fires_without_radius_analogs() -> None:
|
||||
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
|
||||
same-building комплы есть → якорь обязан сработать и дать median>0.
|
||||
До фикса гейт требовал listings_clean+median>0 → якорь скипался."""
|
||||
est = _run_estimate(
|
||||
anchor_comps=_SB_COMPS_PREMIUM,
|
||||
anchor_tier="A",
|
||||
radius_analogs=[],
|
||||
)
|
||||
# Якорь дал ненулевой headline несмотря на пустой радиус.
|
||||
assert est.median_price_rub > 0
|
||||
assert est.median_price_per_m2 >= 450_000
|
||||
# range валиден (low ≤ point ≤ high), не вырожден в 0.
|
||||
assert 0 < est.range_low_rub <= est.median_price_rub <= est.range_high_rub
|
||||
|
||||
|
||||
def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
|
||||
"""#691 контроль: ни радиуса, ни same-building комплов → median остаётся 0
|
||||
(insufficient), фикс НЕ фабрикует число из воздуха."""
|
||||
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=[])
|
||||
assert est.median_price_rub == 0
|
||||
assert est.median_price_per_m2 == 0
|
||||
|
||||
|
||||
def test_estimate_economy_no_regression() -> None:
|
||||
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов."""
|
||||
eco_comps = [
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
*/
|
||||
import { useState } from "react";
|
||||
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
|
||||
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||
import { HeroTransparency } from "./HeroTransparency";
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
|
|
@ -77,6 +79,9 @@ function isMockAddress(estimate: AggregatedEstimate): boolean {
|
|||
export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) {
|
||||
const cv = calcCv(estimate);
|
||||
const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low;
|
||||
// Brand-aware PDF + lead-тема. useActiveBrandSlug (merged) reuse, brand name из /brand.
|
||||
const activeBrandSlug = useActiveBrandSlug();
|
||||
const { data: brand } = useBrand();
|
||||
|
||||
// Бейдж точности гео-привязки адреса (DaData qc_geo). null/undefined → ничего не рисуем.
|
||||
const precisionBadge =
|
||||
|
|
@ -319,6 +324,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Доверие + действия (web-преимущества над PDF): свежесть данных,
|
||||
collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше
|
||||
остаётся чистым — прозрачность вторична/collapsible. ── */}
|
||||
<HeroTransparency
|
||||
estimate={estimate}
|
||||
brandSlug={activeBrandSlug}
|
||||
brandName={brand?.name ?? null}
|
||||
/>
|
||||
|
||||
{/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ──
|
||||
asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст
|
||||
под headline-итогом, поэтому одинакового веса. */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* HeroTransparency — блок «доверие + действия» в HEADER результата трейд-ин.
|
||||
*
|
||||
* Web-преимущества над статичным PDF:
|
||||
* 1. «Как рассчитано» — collapsible (details/summary): уровень аналогов
|
||||
* (парсится из confidence_explanation), n_analogs, человекочитаемый band
|
||||
* достоверности и полный текст объяснения. Доверие appraiser-grade, on demand.
|
||||
* 2. Свежесть данных — строка «Данные актуальны на …» из last_scraped_at либо
|
||||
* now − data_freshness_minutes. Подчёркивает «это не устаревший PDF».
|
||||
* 3. «Скачать PDF-отчёт» — ссылка на brand-aware endpoint (тот же, что в OfferCard).
|
||||
* 4. CTA «Оставить заявку на трейд-ин» — primary. Lead-backend ОТСУТСТВУЕТ
|
||||
* (проверено: в trade_in.py нет /lead-эндпоинта) → graceful mailto-stub
|
||||
* на configurable контакт. TODO(lead-flow) — заменить на реальный POST.
|
||||
*
|
||||
* Тема — через brand-CSS-vars (--accent…), responsive, всё graceful при null-полях.
|
||||
*/
|
||||
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
/** Активный brand slug (useActiveBrandSlug) — для brand-aware PDF + темы письма. */
|
||||
brandSlug: string | null;
|
||||
/** Имя бренда (Brand.name) для темы заявки. null → generic. */
|
||||
brandName: string | null;
|
||||
}
|
||||
|
||||
const CONF_BAND: Record<string, string> = {
|
||||
high: "высокая достоверность",
|
||||
medium: "средняя достоверность",
|
||||
low: "низкая достоверность",
|
||||
};
|
||||
|
||||
/**
|
||||
* Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL.
|
||||
* БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled),
|
||||
* чтобы заявка с PII не ушла на чужой/тестовый адрес (review PR #689).
|
||||
*/
|
||||
const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim();
|
||||
|
||||
/**
|
||||
* Эвристика уровня сопоставимых объектов из confidence_explanation.
|
||||
* Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую.
|
||||
* Null → ничего не показываем (graceful).
|
||||
*/
|
||||
function compsTier(explanation: string | null): string | null {
|
||||
if (!explanation) return null;
|
||||
const t = explanation.toLowerCase();
|
||||
if (t.includes("тот же дом") || t.includes("того же дома") || t.includes("этом же доме"))
|
||||
return "по аналогам того же дома";
|
||||
if (t.includes("≤500") || t.includes("<=500") || t.includes("500 м") || t.includes("окружени"))
|
||||
return "по окружению ≤500 м";
|
||||
if (t.includes("район") || t.includes("микрорайон")) return "по аналогам района";
|
||||
if (t.includes("город")) return "по аналогам города";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** «27 мая 2026, 14:30» */
|
||||
function formatRuDateTime(d: Date): string {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Момент актуальности данных: предпочитаем last_scraped_at (ISO), иначе
|
||||
* now − data_freshness_minutes. Невалидно/нет данных → null (строку не рисуем).
|
||||
*/
|
||||
function freshnessMoment(estimate: AggregatedEstimate): Date | null {
|
||||
if (estimate.last_scraped_at) {
|
||||
const d = new Date(estimate.last_scraped_at);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
}
|
||||
if (typeof estimate.data_freshness_minutes === "number" && estimate.data_freshness_minutes >= 0) {
|
||||
return new Date(Date.now() - estimate.data_freshness_minutes * 60_000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
|
||||
const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low;
|
||||
const tier = compsTier(estimate.confidence_explanation);
|
||||
const fresh = freshnessMoment(estimate);
|
||||
// CTA показываем только при сконфигурированном лид-инбоксе (см. CONTACT_EMAIL) — review #689.
|
||||
const leadEnabled = CONTACT_EMAIL.length > 0;
|
||||
|
||||
// PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label —
|
||||
// тот же паттерн, что в OfferCard, поэтому консистентно.
|
||||
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
|
||||
const pdfHref = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf${pdfQuery}`;
|
||||
|
||||
// Lead-стаб: mailto с предзаполненной темой/телом. Реального POST-эндпоинта нет.
|
||||
const leadSubject = `Заявка на трейд-ин${brandName ? ` · ${brandName}` : ""}`;
|
||||
const leadBody = [
|
||||
"Здравствуйте! Хочу обсудить трейд-ин по моей квартире.",
|
||||
estimate.target_address ? `Адрес: ${estimate.target_address}` : null,
|
||||
`Номер оценки: ${estimate.estimate_id}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const leadHref = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(
|
||||
leadSubject,
|
||||
)}&body=${encodeURIComponent(leadBody)}`;
|
||||
|
||||
return (
|
||||
<div className="hero-trust">
|
||||
<div className="hero-trust-actions">
|
||||
{/* CTA — prominent primary (конверсия = преимущество над «мёртвым» PDF). */}
|
||||
{/* Рендерим ТОЛЬКО при сконфигурированном лид-инбоксе (review #689) — иначе */}
|
||||
{/* заявка с PII ушла бы на чужой/тестовый адрес. TODO(lead-flow): POST /lead. */}
|
||||
{leadEnabled && (
|
||||
<a className="btn btn-accent hero-trust-cta" href={leadHref}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16v12H5.17L4 17.17V4z" />
|
||||
<line x1="8" y1="9" x2="16" y2="9" />
|
||||
<line x1="8" y1="12.5" x2="13" y2="12.5" />
|
||||
</svg>
|
||||
Оставить заявку на трейд-ин
|
||||
</a>
|
||||
)}
|
||||
<a className="btn btn-ghost hero-trust-pdf" href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<polyline points="9 15 12 18 15 15" />
|
||||
</svg>
|
||||
Скачать PDF-отчёт
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{fresh && (
|
||||
<p className="hero-trust-freshness">
|
||||
<span className="dot" aria-hidden="true" />
|
||||
Данные актуальны на <b>{formatRuDateTime(fresh)}</b>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details className="hero-trust-how">
|
||||
<summary>
|
||||
<span className="how-title">Как рассчитано</span>
|
||||
<span className="how-band">{band}</span>
|
||||
<svg className="how-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="how-body">
|
||||
<ul className="how-facts">
|
||||
{tier && (
|
||||
<li>
|
||||
<span className="k">Сопоставление</span>
|
||||
<span className="v">{tier}</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<span className="k">Аналогов в выборке</span>
|
||||
<span className="v mono">{estimate.n_analogs}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="k">Достоверность оценки</span>
|
||||
<span className="v">{band}</span>
|
||||
</li>
|
||||
</ul>
|
||||
{estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && (
|
||||
<p className="how-explanation">{estimate.confidence_explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -874,6 +874,116 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HERO TRUST + ACTIONS — свежесть, «как рассчитано», PDF, CTA
|
||||
(web-преимущества над статичным PDF). Тема через brand --accent.
|
||||
============================================================ */
|
||||
.hero-trust {
|
||||
padding: 14px 22px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.hero-trust-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.hero-trust-cta {
|
||||
flex: 1 1 240px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero-trust-pdf {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.hero-trust-freshness {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.hero-trust-freshness .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success, #16a34a);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--success, #16a34a) 22%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-trust-freshness b { color: var(--fg); font-weight: 600; }
|
||||
|
||||
.hero-trust-how {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-trust-how > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
user-select: none;
|
||||
}
|
||||
.hero-trust-how > summary::-webkit-details-marker { display: none; }
|
||||
.hero-trust-how .how-title { font-weight: 600; }
|
||||
.hero-trust-how .how-band {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
background: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.hero-trust-how .how-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.hero-trust-how[open] .how-chevron { transform: rotate(180deg); }
|
||||
.hero-trust-how .how-body {
|
||||
padding: 4px 14px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.hero-trust-how .how-facts {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.hero-trust-how .how-facts li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.hero-trust-how .how-facts .k { color: var(--muted); }
|
||||
.hero-trust-how .how-facts .v { color: var(--fg); font-weight: 500; text-align: right; }
|
||||
.hero-trust-how .how-facts .v.mono { font-family: var(--font-mono); }
|
||||
.hero-trust-how .how-explanation {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-2);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.hero-trust-cta, .hero-trust-pdf { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FILTER + SOURCE chips
|
||||
============================================================ */
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export interface AggregatedEstimate {
|
|||
target_lon: number | null;
|
||||
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||
est_days_on_market: number | null; // прогноз срока продажи
|
||||
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
||||
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
||||
|
|
@ -157,12 +158,10 @@ export interface AggregatedEstimate {
|
|||
avito_imv?: AvitoImvSummary | null;
|
||||
dkp_corridor?: DkpCorridor | null;
|
||||
// ── ANALYTICS surface (web-native cards) ──
|
||||
// price_trend — динамика медианы ₽/м² по месяцам для здания/района
|
||||
// (PriceTrendCard). null / <2 точек → карточка не рендерится.
|
||||
// last_scraped_at — ISO datetime последнего скрейпа данных аналитики.
|
||||
// Оба optional + nullable: старые оценки их не содержат (graceful).
|
||||
// price_trend — динамика медианы ₽/м² по месяцам для здания/района
|
||||
// (PriceTrendCard). null / <2 точек → карточка не рендерится.
|
||||
// last_scraped_at объявлен выше (был дубль #688+#689 → ломал build).
|
||||
price_trend?: PriceTrendPoint[] | null;
|
||||
last_scraped_at?: string | null;
|
||||
}
|
||||
|
||||
// ── Stage 4a/4b response types ──
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue