Compare commits

..

3 commits

Author SHA1 Message Date
668f8c6ffb fix(ptica): backfill act_date у 11 строк, куда уехала дата Генплана-2004 (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m40s
CI / backend-tests (pull_request) Successful in 17m27s
заканчивается `ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому
пятничный прогон (`0 7 * * fri`) существующие строки не перезапишет.
Без этой миграции 11 строк остались бы с датой 2004 года навсегда —
правка выглядела бы сделанной, а данные на проде остались бы кривыми.

Замер прода 20.08.2026:

    89adb28a…  развязка Базовый/Комсомольская/Сибирский тракт   9 строк
    9b9d9a99…  улица Энергостроителей                           2 строки
    обе группы: act_date = 2004-07-06

Верные даты не угаданы: оба PDF загружены с екатеринбург.рф и
распознаны тем же трактом, что использует загрузчик (ocr_pdf_text), и в
обоих настоящее основание — постановление Администрации города:
№ 1413 от 27.05.2022 и № 259 от 12.02.2020 соответственно.

Сужение по doc_url обязательно: без него UPDATE задел бы любую строку с
06.07.2004, включая те, где эта дата настоящая. Миграция идемпотентна —
условие `act_date = '2004-07-06'` при повторе не выполнится.

Тест герметичный, прогоняет ТЕЛО миграции целиком на временной копии в
прод-форме (9+2 целевых + 2 контрольных посторонних). Контроль-двойник
`test_without_the_migration_rows_stay_wrong` обязателен: без него тест
неотличим от «оно и так было правильно». Мутационно проверено сужение —
снятие условия по doc_url роняет
test_other_documents_with_same_date_are_untouched.

pytest backend/tests/sql/test_2464_act_date_backfill.py — 6 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:11:35 +05:00
91a5207641 fix(ptica): лоты без площади — своя корзина, а не «<25 м²» (#2464)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m4s
Deploy / build-worker (push) Successful in 3m16s
Deploy / deploy (push) Successful in 1m33s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 10s
Часть эпика #2464.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:02:25 +00:00
1b2a26bb9a fix(ptica): лоты без площади — своя корзина, а не «<25 м²» (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m27s
CI / backend-tests (pull_request) Successful in 17m13s
`_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину
`'<25'`, что и настоящие студии. Замер прода 20.08.2026 (последний
снапшот на физлот, premise_kind='квартира', не проданные):

    в продаже            181 353
    без area_pd           11 557   (6.4 %)
    реально < 25 м²        7 013

Корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких
лотов в блоке «По предложению (без темпа продаж)».

Зеркала у такого отображения не было: `layout_signature.area_bin`
принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади
не группирует — то есть `NULL → '<25'` было выдумкой, а не переносом
чужого правила.

Исключать такие лоты нельзя: они реально в продаже, и без них
предложение занизилось бы на 6.4 %. Поэтому отдельная корзина «н/д».
Медиана площади у неё выйдет NULL (PERCENTILE_CONT игнорирует NULL) —
честно. Схема не меняется: area_bin остаётся str, OpenAPI прежний.

Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица
objective_lots затеняет боевую в пределах сессии, запрос берётся из
модуля дословно, прод-данные не читаются.

Двусторонне: против origin/main корзины распределяются как
{'<25': 2, '25-40': 1, '40-60': 1} — конкретное неверное значение, ни
одного TypeError/ImportError. Контроли (сумма лотов сохраняется,
обычные корзины не меняются) зелёные с обеих сторон.

pytest backend/tests/sql/ — 38 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:41:47 +05:00
3 changed files with 189 additions and 1 deletions

View file

@ -413,8 +413,18 @@ _SUPPLY_ONLY_LOTS_SQL = text("""
WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text
ELSE '4+'
END AS rb,
-- #2464: площадь неизвестна — это ОТДЕЛЬНАЯ корзина, а не «<25».
-- Прежде NULL сваливался к настоящим студиям: на проде 20.08.2026
-- в продаже 11 557 квартир без area_pd против 7 013 реально
-- меньших 25 м², то есть корзина «<25» на 62 % состояла из
-- неизвестного и завышала долю мелких лотов в структуре остатков.
-- Зеркала у этого отображения не было: layout_signature.area_bin
-- принимает float и NULL-ветки не имеет вовсе.
-- Исключать такие лоты нельзя они реально в продаже, и без них
-- предложение занижалось бы на 6.4 %. Медиана площади у этой
-- корзины выйдет NULL (PERCENTILE_CONT игнорирует NULL) честно.
CASE
WHEN area_pd IS NULL THEN '<25'
WHEN area_pd IS NULL THEN 'н/д'
WHEN area_pd < 25 THEN '<25'
WHEN area_pd < 40 THEN '25-40'
WHEN area_pd < 60 THEN '40-60'

View file

@ -135,6 +135,15 @@ tests/sql/test_2464_default_profile_unique.py::test_without_migration_two_defaul
tests/sql/test_2464_default_profile_unique.py::test_reassigning_default_still_works
tests/sql/test_2464_default_profile_unique.py::test_different_users_keep_their_own_defaults
# ── #2464: корзина площади «н/д» в supply-only (best_layouts) ─────────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ таблицу objective_lots, которая
# затеняет боевую в пределах сессии, и прогоняют НАСТОЯЩИЙ _SUPPLY_ONLY_LOTS_SQL
# на засеянных лотах. В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины
# без БД и без туннеля.
tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as_tiny
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции

View file

@ -0,0 +1,169 @@
"""Лоты без площади — отдельная корзина, а не «<25 м²» (#2464).
`_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину `'<25'`,
что и настоящие студии. Замер прода 20.08.2026 (последний снапшот на физлот,
premise_kind='квартира', не проданные):
в продаже 181 353
без area_pd 11 557 (6.4 %)
реально < 25 м² 7 013
То есть корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких
лотов в структуре остатков это блок «По предложению (без темпа продаж)»
отчёта. Зеркала у такого отображения не было: `layout_signature.area_bin`
принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади не
группирует. Исключать лоты тоже нельзя они реально в продаже.
Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица `objective_lots`
затеняет боевую в пределах сессии (temp-схема идёт раньше public в search_path),
поэтому запрос из модуля выполняется дословно, а прод-данные не читаются.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
)
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _db_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
c.execute(text("SELECT 1"))
return True, ""
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _db_reachable()
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
_TEMP_TABLE = """
CREATE TEMP TABLE objective_lots (
id bigserial PRIMARY KEY,
project_name text,
corpus_name text,
section text,
floor integer,
lot_number text,
rooms_int smallint,
area_pd numeric,
price_per_m2_rub numeric,
is_sold boolean,
status text,
premise_kind text,
snapshot_date date
) ON COMMIT DROP
"""
# Один проект, четыре лота: без площади, настоящая студия, 30 м², 50 м².
_ROWS = [
("ЖК Пример", "к1", "с1", 3, "101", 1, None, 200000, "квартира"),
("ЖК Пример", "к1", "с1", 3, "102", 1, Decimal("22.0"), 200000, "квартира"),
("ЖК Пример", "к1", "с1", 4, "201", 1, Decimal("30.0"), 210000, "квартира"),
("ЖК Пример", "к1", "с1", 5, "301", 2, Decimal("50.0"), 220000, "квартира"),
]
def _buckets() -> dict[str, int]:
"""Выполнить настоящий запрос модуля на временной таблице → {корзина: лотов}."""
from app.services.site_finder.best_layouts import (
_SUPPLY_ONLY_LOTS_SQL,
_SUPPLY_ONLY_PREMISE_KIND,
_SUPPLY_ONLY_PRICE_MAX,
_SUPPLY_ONLY_PRICE_MIN,
)
session = sessionmaker(bind=create_engine(_dsn()))()
try:
session.execute(text(_TEMP_TABLE))
for pn, corp, sec, fl, lot, rooms, area, price, kind in _ROWS:
session.execute(
text(
"INSERT INTO objective_lots (project_name, corpus_name, section, floor,"
" lot_number, rooms_int, area_pd, price_per_m2_rub, is_sold, status,"
" premise_kind, snapshot_date) VALUES (:pn,:corp,:sec,:fl,:lot,:rooms,"
":area,:price,false,'в продаже',:kind,CURRENT_DATE)"
),
{
"pn": pn,
"corp": corp,
"sec": sec,
"fl": fl,
"lot": lot,
"rooms": rooms,
"area": area,
"price": price,
"kind": kind,
},
)
rows = (
session.execute(
_SUPPLY_ONLY_LOTS_SQL,
{
"names": ["ЖК Пример"],
"premise_kind": _SUPPLY_ONLY_PREMISE_KIND,
"price_min": _SUPPLY_ONLY_PRICE_MIN,
"price_max": _SUPPLY_ONLY_PRICE_MAX,
},
)
.mappings()
.all()
)
return {str(r["area_bin"]): int(r["lots_count"]) for r in rows}
finally:
session.rollback()
session.close()
def test_lot_without_area_is_not_counted_as_tiny() -> None:
"""Головной: лот без площади не должен попадать в корзину настоящих студий.
На origin/main корзина «<25» содержит 2 лота вместо одного второй это
лот с area_pd IS NULL.
"""
b = _buckets()
assert b.get("<25") == 1, (
f"в корзину «<25» попал лот без площади: {b} — на проде так туда "
"уезжают 11 557 квартир при 7 013 настоящих"
)
def test_unknown_area_gets_its_own_bucket() -> None:
"""Лот без площади обязан быть виден отдельно, а не исчезнуть.
Исключение занизило бы предложение на 6.4 %: лоты реально в продаже.
"""
b = _buckets()
assert b.get("н/д") == 1, f"корзины «н/д» нет — лот без площади потерян: {b}"
def test_total_lots_preserved() -> None:
"""Контроль сохранности: сумма по корзинам равна числу лотов в продаже."""
b = _buckets()
assert sum(b.values()) == len(_ROWS), f"лоты потеряны или задвоены: {b}"
def test_normal_buckets_unchanged() -> None:
"""Контроль от переусердствования: обычные площади раскладываются как раньше."""
b = _buckets()
assert b.get("25-40") == 1, f"корзина 25-40: {b}"
assert b.get("40-60") == 1, f"корзина 40-60: {b}"