fix(generative): forward genuine price_source through /recompute (#1965 Stage 2a)
Some checks failed
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Failing after 1m49s
CI / backend-tests (pull_request) Successful in 13m49s

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.
This commit is contained in:
Light1YT 2026-06-28 00:33:59 +05:00
parent d38d5d43c5
commit 18d012da1b
3 changed files with 48 additions and 2 deletions

View file

@ -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) — деградация уже внутри

View file

@ -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(

View file

@ -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)