feat(tradein): brand-by-account + result IA redesign (distinct asking/sale prices) (#683)
All checks were successful
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / test (push) Successful in 23s
Deploy Trade-In / build-backend (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 36s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-30 06:45:14 +00:00 committed by bot-reviewer
parent 6fac54642a
commit f43d593cf6
8 changed files with 321 additions and 81 deletions

View file

@ -41,6 +41,28 @@ class UserScope(TypedDict):
role: Role
allowed_paths: list[str]
deny_paths: list[str]
# #657 white-label: brand slug, привязанный к аккаунту. None = generic UI.
# Фронт (useBrand.ts) применяет бренд автоматически на login (без ?brand=).
brand: str | None
# ---------------------------------------------------------------------------
# Account → brand mapping (#657 brand-by-account)
# ---------------------------------------------------------------------------
#
# Пилот-аккаунты агентства «Практика» получают брендирование автоматически
# при логине (без ?brand=). Резолвится в /me как UserScope.brand. Остальные
# юзеры → None (стандартный generic UI). Slug должен существовать в таблице
# `brands` (см. services/brand.py) — иначе useBrand fallback на generic.
_USERNAME_BRAND: dict[str, str] = {
"praktika": "praktika",
"kopylov": "praktika",
}
def get_brand_for_user(username: str) -> str | None:
"""Return the brand slug bound to *username*, or None for generic UI."""
return _USERNAME_BRAND.get(username)
# ---------------------------------------------------------------------------
@ -176,6 +198,7 @@ def get_user_scope(username: str) -> UserScope:
role=role,
allowed_paths=list(role_def.get("paths", []) or []),
deny_paths=list(role_def.get("deny", []) or []),
brand=get_brand_for_user(username),
)

View file

@ -0,0 +1,50 @@
"""Tests for #657 brand-by-account — UserScope.brand resolution.
Pure-function tests against app.core.auth: brand slug привязывается к аккаунту
(praktika/kopylov "praktika"), остальные юзеры None. get_user_scope
прокидывает brand в scope, который /me отдаёт фронту.
Без БД и сети: читается реальный auth/roles.yaml (ancestor-walk внутри репо).
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
# DATABASE_URL required by config before any app import (см. test_dadata.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed без GTK (consistent с прочими тестами).
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.core.auth import get_brand_for_user, get_user_scope # noqa: E402
@pytest.mark.parametrize(
("username", "expected"),
[
("praktika", "praktika"),
("kopylov", "praktika"),
("admin", None),
("user1", None),
("unknown_user", None),
],
)
def test_get_brand_for_user(username: str, expected: str | None) -> None:
assert get_brand_for_user(username) == expected
def test_user_scope_carries_brand_for_praktika() -> None:
scope = get_user_scope("praktika")
assert scope["brand"] == "praktika"
assert scope["username"] == "praktika"
def test_user_scope_brand_none_for_generic_user() -> None:
scope = get_user_scope("user1")
assert scope["brand"] is None

View file

@ -29,7 +29,7 @@ import { OfferCard } from "@/components/trade-in/OfferCard";
import { TestPresets } from "@/components/trade-in/TestPresets";
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
import { useBrand, getActiveBrandSlug } from "@/lib/useBrand";
import { useBrand, useActiveBrandSlug } from "@/lib/useBrand";
function useEstimateId() {
if (typeof window === "undefined") return null;
@ -45,7 +45,7 @@ export default function TradeInPage() {
// #657 white-label: «отдельная страничка под Практику» = re-skin того же
// UI при ?brand=praktika. Бренд резолвится из ?brand= slug (см. useBrand.ts).
const { data: brand } = useBrand();
const activeBrandSlug = getActiveBrandSlug();
const activeBrandSlug = useActiveBrandSlug();
// Переопределяем CSS-переменные палитры в рантайме + вешаем .brand-active
// (переключает --font-sans на Manrope, см. trade-in.css). Cleanup снимает

View file

@ -31,6 +31,24 @@ const REPAIR_LABELS: Record<string, string> = {
excellent: "Евроремонт",
};
const SOURCE_DOTS: Record<string, string> = {
cian: "cian",
avito: "avito",
yandex: "yandex",
domklik: "dom",
rosreestr: "rosreestr",
n1: "n1",
};
const SOURCE_LABELS: Record<string, string> = {
cian: "Циан",
avito: "Avito",
yandex: "Я.Недвижимость",
domklik: "ДомКлик",
rosreestr: "Росреестр",
n1: "N1.ru",
};
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
high: { txt: "высокая", color: "var(--success)" },
medium: { txt: "средняя", color: "var(--success)" },
@ -266,15 +284,53 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div>
</div>
<div className={`hero-prices${hasSold ? "" : " single"}`}>
{/* ЕДИНЫЙ итог: «Ожидаемая цена сделки» (S2). Доминирующий headline.
При наличии expected_sold показываем его как главный outcome (ниже
запроса, с X%). Без него (старые оценки / пустая ratio) headline =
asking-медиана, чтобы блок не оставался без главного числа. */}
<div className="hero-headline">
<div className="headline-label">
{hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"}
{showDiscount && (
<span
className="headline-delta"
aria-label={`на ${discountPct}% ниже рекомендованной цены в объявлении`}
>
{discountPct}% к объявлению
</span>
)}
</div>
<div className="headline-value mono">
{formatMln(hasSold ? (sold as number) : m)}
</div>
{hasSold && typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="headline-range mono">
ожидаемый диапазон {formatMln(soldLo)} {formatMln(soldHi)}
{typeof soldPerM2 === "number"
? ` · ${soldPerM2.toLocaleString("ru-RU")} ₽/м²`
: ""}
</div>
)}
{hasSold && (
<p className="headline-note">
Реальные сделки проходят на <b>512% ниже</b> цен в объявлениях это
ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса.
</p>
)}
</div>
{/* Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники)
asking (объявления) vs реальные сделки ДКП. Оба рыночный контекст
под headline-итогом, поэтому одинакового веса. */}
<div className={`hero-prices${showDkp ? "" : " single"}`}>
<div className="price-figure" data-kind="asking">
<div className="price-figure-label">Ориентир запроса</div>
<div className="price-figure-label">Рекомендованная цена в объявлении</div>
<div className="price-figure-value mono">{formatMln(m)} </div>
<div className="price-figure-range mono">
{formatMln(lo)} {formatMln(hi)}
</div>
<div className="price-figure-perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м² · по объявлениям
</div>
{imvPrice !== null && (
<div
@ -287,53 +343,25 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
)}
</div>
{hasSold && (
<div className="price-figure" data-kind="sold">
{showDkp && dkp && (
<div className="price-figure" data-kind="dkp">
<div className="price-figure-label">
Ожидаемая цена продажи
{showDiscount && (
<span className="price-figure-delta" aria-label={`на ${discountPct}% ниже запроса`}>
{discountPct}%
</span>
)}
Цены по фактическим сделкам (ДКП · Росреестр)
</div>
<div className="price-figure-value mono">
{formatMln(dkp.median_ppm2 * dkpArea)}
</div>
<div className="price-figure-range mono">
{formatMln(dkp.low_ppm2 * dkpArea)} {formatMln(dkp.high_ppm2 * dkpArea)}
</div>
<div className="price-figure-perm2 mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} ДКП за{" "}
{dkp.period_months} мес
</div>
<div className="price-figure-value mono">{formatMln(sold as number)} </div>
{typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="price-figure-range mono">
{formatMln(soldLo)} {formatMln(soldHi)}
</div>
)}
{typeof soldPerM2 === "number" && (
<div className="price-figure-perm2 mono">
{soldPerM2.toLocaleString("ru-RU")} /м²
</div>
)}
</div>
)}
</div>
{hasSold && (
<p className="hero-prices-note">
<b>Запрос</b> по чему выставлены сопоставимые квартиры в объявлениях.{" "}
<b>Ожидаемая цена продажи</b> реалистичная цена сделки по данным ДКП Росреестра, обычно
ниже запроса.
</p>
)}
{showDkp && dkp && (
<p
className="hero-prices-note"
title="Коридор реальных ₽/м² по ДКП-сделкам Росреестра (справочно)"
>
<b>Коридор реальных сделок:</b>{" "}
<span className="mono">
{formatMln(dkp.low_ppm2 * dkpArea)} {formatMln(dkp.high_ppm2 * dkpArea)}
</span>{" "}
· <span className="mono">{dkp.median_ppm2.toLocaleString("ru-RU")} /м²</span> ·{" "}
{dkp.count} ДКП (Росреестр)
</p>
)}
<div className="hero-bars">
<div className="bar-block">
<div className="bar-head">
@ -408,8 +436,24 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
})()}
</div>
{estimate.confidence_explanation && (
<div className="hero-warnings">
{/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ── */}
<div className="filters-row" style={{ padding: "14px 22px 0" }}>
<span className="lbl">Источники</span>
<div className="sources">
{(estimate.sources_used.length > 0
? estimate.sources_used
: ["avito", "cian", "yandex", "rosreestr"]
).map((src) => (
<span key={src} className="source-chip">
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
{SOURCE_LABELS[src] ?? src}
</span>
))}
</div>
</div>
<div className="hero-warnings">
{estimate.confidence_explanation && (
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
@ -420,32 +464,48 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</span>
<div>{estimate.confidence_explanation}</div>
</div>
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
Цены в объявлениях реальная сделка разница <b>512%</b> по данным Росреестра.
</div>
</div>
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
Самостоятельная продажа = до <b>15% потерь</b> на торге, риелторе, нотариусе и аренде.
</div>
)}
{/* 3 статичных explainer-карточки (#648 IA, всегда показываем). */}
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
<b>Цены в объявлениях это ожидания собственников.</b> Реальные сделки
проходят на <b>512% ниже</b> по данным ДКП Росреестра.
</div>
</div>
)}
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
<b>Ремонт оценивается по состоянию, а не по вложенным суммам.</b>{" "}
Рынок платит за итоговый вид квартиры, а не за чеки на материалы.
</div>
</div>
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
<b>Скрытые расходы при самостоятельной продаже до 15%</b> на торге,
риелторе, нотариусе и аренде жилья на время сделки.
</div>
</div>
</div>
{showEnrichment && (
<div

View file

@ -1,8 +1,19 @@
"use client";
/**
* OfferCard Секция 4 «Оффер» из mockup tradein.html.
* Сравнение trade-in vs самостоятельная продажа + 4 преимущества + CTA.
* OfferCard Секция 4 «Оффер» (commercial Brusnika p.4 «формирование
* выкупной стоимости»).
*
* STUB / DEFER (#648 IA redesign): сама ВЫКУПНАЯ цена (сколько девелопер
* платит клиенту за квартиру в trade-in) пока НЕ рассчитывается это
* коммерческое решение, требующее условий «Практики» (haircut к ожидаемой
* цене сделки, маржа, срок фиксации). Не выдумываем число.
*
* TODO(#648 commercial): когда «Практика» даст коммерческие параметры
* добавить блок «Выкупная стоимость = ожидаемая цена сделки выкупной
* дисконт N%», feeding от estimate.expected_sold_price_rub. До этого карточка
* показывает только illustrative сравнение издержек самостоятельной продажи
* (типовые рыночные ставки, НЕ оффер «Практики») + преимущества + PDF CTA.
*/
import type { AggregatedEstimate } from "@/types/trade-in";
import { API_BASE_URL } from "@/lib/api";
@ -47,6 +58,27 @@ export function OfferCard({ estimate, brandSlug }: Props) {
</div>
</div>
{/* STUB: выкупная цена ещё не считается (нужны коммерческие условия
«Практики»). Ниже только illustrative сравнение издержек, не оффер. */}
<div
role="note"
style={{
margin: "0 22px 4px",
padding: "10px 14px",
border: "1px dashed var(--border-strong, var(--border))",
background: "var(--surface-2)",
borderRadius: "var(--radius)",
fontSize: 12,
lineHeight: 1.5,
color: "var(--muted)",
}}
>
Итоговая <b>выкупная цена</b> по trade-in (сколько получит клиент)
формируется индивидуально на основе ожидаемой цены сделки и коммерческих
условий в этой версии не рассчитывается автоматически. Ниже
ориентировочное сравнение издержек самостоятельной продажи.
</div>
<table className="offer-tbl" aria-label="Сравнение trade-in и самостоятельной продажи">
<thead>
<tr>

View file

@ -661,6 +661,63 @@
background: var(--accent-soft);
border-color: color-mix(in oklch, var(--accent) 22%, var(--border));
}
/* ДКП-сделки (Росреестр) нейтральный «факт-рынок» оттенок, отличный от
asking, чтобы читались как ДВА разных источника. */
.price-figure[data-kind="dkp"] {
background: var(--surface-3);
border-color: var(--border-strong, var(--border));
}
/* Единый headline-итог: «Ожидаемая цена сделки» (#648 IA redesign)
Доминирует над двумя reference-figure ниже: больший размер value,
акцентный фон. Снимает «два одинаковых числа» это ОДНО главное число. */
.hero-headline {
padding: 20px 22px 16px;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--accent-soft);
border-bottom: 1px solid color-mix(in oklch, var(--accent) 18%, var(--border));
}
.headline-label {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--accent-ink);
}
.headline-delta {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: color-mix(in oklch, var(--accent) 16%, transparent);
color: var(--accent-ink);
}
.headline-value {
font-size: 38px;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.025em;
color: var(--fg);
}
.headline-range {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.01em;
}
.headline-note {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--fg-2);
max-width: 60ch;
}
.headline-note b { font-weight: 700; }
.price-figure-label {
display: flex;
align-items: center;

View file

@ -3,9 +3,11 @@
/**
* useBrand white-label resolver для #657 («Практика» и пр.).
*
* Активный бренд определяется query-параметром `?brand=<slug>` в URL
* (НЕ из /me у юзера нет brand/account-поля). Default нет бренда
* (стандартный UI prinzip/generic), хук возвращает {data: null}.
* Активный бренд резолвится из ДВУХ источников (param wins):
* 1. query-параметр `?brand=<slug>` в URL явный override (демо/превью);
* 2. `me.brand` из /me привязка бренда к аккаунту (#657 brand-by-account):
* praktika/kopylov логинятся брендированный UI автоматически, без ?brand=.
* Если ни один не задан нет бренда (generic UI), хук возвращает {data: null}.
*
* Mirror паттерна useMe.ts: TanStack Query, staleTime/gcTime Infinity
* (бренд статичен на сессию). Backend: GET /api/v1/brand/{slug}
@ -16,6 +18,7 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import { useMe } from "@/lib/useMe";
export interface Brand {
slug: string;
@ -28,22 +31,34 @@ export interface Brand {
}
/** Читает `?brand=` slug из URL. SSR-safe (null на сервере). */
export function getActiveBrandSlug(): string | null {
export function getBrandSlugFromUrl(): string | null {
if (typeof window === "undefined") return null;
const slug = new URLSearchParams(window.location.search).get("brand");
return slug && slug.trim() ? slug.trim().toLowerCase() : null;
}
/**
* Активный slug бренда: `?brand=` param ПЕРЕВЕШИВАЕТ me.brand (account binding).
* Hook (читает useMe) вызывать только в React-компонентах.
*/
export function useActiveBrandSlug(): string | null {
const { data: me } = useMe();
const urlSlug = getBrandSlugFromUrl();
if (urlSlug) return urlSlug;
const accountSlug = me?.brand;
return accountSlug ? accountSlug.trim().toLowerCase() : null;
}
async function fetchBrand(slug: string): Promise<Brand> {
return apiFetch<Brand>(`/api/v1/brand/${encodeURIComponent(slug)}`);
}
/**
* Возвращает активный бренд или {data: null}, если `?brand=` отсутствует.
* Запрос включён только при наличии slug без бренда хук no-op.
* Возвращает активный бренд или {data: null}, если бренд не задан ни в URL,
* ни в аккаунте. Запрос включён только при наличии slug иначе хук no-op.
*/
export function useBrand() {
const slug = getActiveBrandSlug();
const slug = useActiveBrandSlug();
return useQuery<Brand | null, Error>({
queryKey: ["brand", slug],
queryFn: () => (slug ? fetchBrand(slug) : Promise.resolve(null)),

View file

@ -21,6 +21,9 @@ export interface UserScope {
role: Role;
allowed_paths: string[];
deny_paths: string[];
// #657 brand-by-account: slug бренда, привязанный к аккаунту (praktika→"praktika").
// null/undefined = generic UI. useBrand применяет его автоматически на login.
brand?: string | null;
}
export const ME_QUERY_KEY = ["auth", "me"] as const;