From 18d012da1b0adf9995ca5656eef53a5fb58caf03 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 28 Jun 2026 00:33:59 +0500 Subject: [PATCH] fix(generative): forward genuine price_source through /recompute (#1965 Stage 2a) Code-review follow-up: /recompute hardcoded price_source="objective_district_median" for any body-supplied market_price_per_sqm, mislabeling the honesty-flag once Stage 2b forwards a price whose genuine source differs (objective_geo_radius / district_reference / class_norm from financial_estimate). - schemas/concept.py: add optional price_source: str | None to MassingProgram. - api/v1/concepts.py: on FAST path use payload.price_source if provided, else the default label (now a module-level constant _DEFAULT_PRERESOLVED_SOURCE). DB-fallback and class-norm paths keep their own resolved source unchanged. - tests: assert body-provided price_source echoes through to financial.price_source (not overwritten), and the default label applies when the front omits it. --- backend/app/api/v1/concepts.py | 16 +++++++++-- backend/app/schemas/concept.py | 7 +++++ .../tests/api/v1/test_concept_recompute.py | 27 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/backend/app/api/v1/concepts.py b/backend/app/api/v1/concepts.py index 3d2f8d35..03c5a9ec 100644 --- a/backend/app/api/v1/concepts.py +++ b/backend/app/api/v1/concepts.py @@ -40,6 +40,11 @@ _DISTRICT_SEARCH_RADIUS_M: int = 5000 _PRICE_MIN_RUB: int = 30_000 _PRICE_MAX_RUB: int = 600_000 +# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда +# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне +# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median. +_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median" + # Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены. # Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён). # ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT. @@ -234,7 +239,11 @@ async def recompute_massing( Цена продажи жилья: * ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) — - фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. + фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник + берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из + financial_estimate — objective_geo_radius / objective_district_median / + district_reference / class_norm); если фронт его не прислал — дефолтный лейбл. + НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены. * иначе, если передан ``parcel_centroid_wkt`` → ``_lookup_market_price`` по центроиду через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy не должен блокировать event loop). Падение lookup'а → норматив класса, не 500. @@ -245,7 +254,10 @@ async def recompute_massing( price_source: str = "class_norm" if market_price is not None: # FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke. - price_source = "objective_district_median" + # Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если + # его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно + # форсит class_norm, но сюда мы попадаем только при заданной цене.) + price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE elif payload.parcel_centroid_wkt: # Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept). # Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index e19f7d05..0dfdd1fa 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -183,6 +183,13 @@ class MassingProgram(BaseModel): market_price_per_sqm: float | None = Field( None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)" ) + # ПОДЛИННЫЙ источник предрезолвленной цены, который фронт получил из financial_estimate + # (objective_geo_radius / objective_district_median / district_reference / class_norm). + # Honest-флаг для UI/PDF: НЕ подменяем его захардкоженным лейблом. Применяется ТОЛЬКО + # вместе с market_price_per_sqm; None → дефолтный лейбл (см. эндпоинт recompute_massing). + price_source: str | None = Field( + None, description="Подлинный источник предрезолвленной цены (forward из financial_estimate)" + ) # Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm # не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки). parcel_centroid_wkt: str | None = Field( diff --git a/backend/tests/api/v1/test_concept_recompute.py b/backend/tests/api/v1/test_concept_recompute.py index 435a7d9b..8176986f 100644 --- a/backend/tests/api/v1/test_concept_recompute.py +++ b/backend/tests/api/v1/test_concept_recompute.py @@ -69,6 +69,33 @@ def test_recompute_market_price_passthrough_skips_db() -> None: # Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена. assert fin["price_per_sqm_used"] == 180_000.0 assert fin["price_is_calibrated"] is True + # Без body price_source → дефолтный лейбл (фронт не форвардил подлинный source). + assert fin["price_source"] == "objective_district_median" + finally: + app.dependency_overrides.pop(get_db, None) + + +def test_recompute_body_price_source_echoed_through() -> None: + # Фронт форвардит ПОДЛИННЫЙ source цены (из financial_estimate, напр. geo_radius) — + # эндпоинт НЕ подменяет его захардкоженным дефолтом; honest-флаг доходит до UI/PDF. + # get_db взрывается → доказывает, что и source-passthrough идёт по FAST path без БД. + class _ExplodingSession: + def execute(self, *_a: object, **_k: object) -> None: + raise AssertionError("DB lookup must be skipped when market_price_per_sqm given") + + app.dependency_overrides[get_db] = lambda: _ExplodingSession() + try: + client = TestClient(app) + resp = client.post( + "/api/v1/concepts/recompute", + json=_program(market_price_per_sqm=160_000.0, price_source="objective_geo_radius"), + ) + assert resp.status_code == 200, resp.text + fin = resp.json()["financial"] + assert fin["price_per_sqm_used"] == 160_000.0 + assert fin["price_is_calibrated"] is True + # Подлинный source эхнут наружу, НЕ перетёрт дефолтом "objective_district_median". + assert fin["price_source"] == "objective_geo_radius" finally: app.dependency_overrides.pop(get_db, None)