Compare commits
3 commits
4e6ed8c044
...
668f8c6ffb
| Author | SHA1 | Date | |
|---|---|---|---|
| 668f8c6ffb | |||
| 91a5207641 | |||
| 1b2a26bb9a |
5 changed files with 448 additions and 1 deletions
|
|
@ -413,8 +413,18 @@ _SUPPLY_ONLY_LOTS_SQL = text("""
|
||||||
WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text
|
WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text
|
||||||
ELSE '4+'
|
ELSE '4+'
|
||||||
END AS rb,
|
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
|
CASE
|
||||||
WHEN area_pd IS NULL THEN '<25'
|
WHEN area_pd IS NULL THEN 'н/д'
|
||||||
WHEN area_pd < 25 THEN '<25'
|
WHEN area_pd < 25 THEN '<25'
|
||||||
WHEN area_pd < 40 THEN '25-40'
|
WHEN area_pd < 40 THEN '25-40'
|
||||||
WHEN area_pd < 60 THEN '40-60'
|
WHEN area_pd < 60 THEN '40-60'
|
||||||
|
|
|
||||||
|
|
@ -134,3 +134,23 @@ tests/sql/test_2464_default_profile_unique.py::test_second_default_is_rejected_b
|
||||||
tests/sql/test_2464_default_profile_unique.py::test_without_migration_two_defaults_slip_through
|
tests/sql/test_2464_default_profile_unique.py::test_without_migration_two_defaults_slip_through
|
||||||
tests/sql/test_2464_default_profile_unique.py::test_reassigning_default_still_works
|
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
|
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 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||||
|
# целиком. В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД.
|
||||||
|
tests/sql/test_2464_act_date_backfill.py::test_genplan_dates_are_replaced_with_real_ones
|
||||||
|
tests/sql/test_2464_act_date_backfill.py::test_no_2004_rows_left_in_target_documents
|
||||||
|
tests/sql/test_2464_act_date_backfill.py::test_other_documents_with_same_date_are_untouched
|
||||||
|
tests/sql/test_2464_act_date_backfill.py::test_correct_rows_do_not_move
|
||||||
|
tests/sql/test_2464_act_date_backfill.py::test_migration_is_idempotent
|
||||||
|
tests/sql/test_2464_act_date_backfill.py::test_without_the_migration_rows_stay_wrong
|
||||||
|
|
|
||||||
206
backend/tests/sql/test_2464_act_date_backfill.py
Normal file
206
backend/tests/sql/test_2464_act_date_backfill.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""Миграция 191 чинит ровно 11 строк с датой Генплана и ничего кроме (#2464).
|
||||||
|
|
||||||
|
До #2981 `_extract_act_date` брал первое «от DD.MM.YYYY» во всём OCR-тексте, и в
|
||||||
|
`act_date` уезжала дата из «Решение Екатеринбургской городской Думы от 06.07.2004
|
||||||
|
№ 60/1 «Об утверждении Генерального плана города»».
|
||||||
|
|
||||||
|
#2981 чинит извлечение **только вперёд**: UPSERT загрузчика заканчивается
|
||||||
|
`ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому пятничный прогон существующие
|
||||||
|
строки не перезапишет. Отсюда разовая миграция.
|
||||||
|
|
||||||
|
Замер прода 20.08.2026: 11 строк с `act_date='2004-07-06'` — 9 по документу
|
||||||
|
89adb28a… (развязка) и 2 по 9b9d9a99… (Энергостроителей). Верные даты взяты из
|
||||||
|
самих PDF, распознанных тем же трактом, что использует загрузчик.
|
||||||
|
|
||||||
|
Тест герметичный: временная копия таблицы, боевые данные не читаются и не меняются.
|
||||||
|
Проверяется тело миграции целиком, а не переписанный от руки UPDATE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
_MIGRATION = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "data"
|
||||||
|
/ "sql"
|
||||||
|
/ "191_land_reservation_act_date_backfill.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
_URL_РАЗВЯЗКА = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
|
||||||
|
_URL_ЭНЕРГО = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
|
||||||
|
_URL_ЧУЖОЙ = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/ffffffffffffffffffffffffffffffff"
|
||||||
|
|
||||||
|
_TEMP = """
|
||||||
|
CREATE TEMP TABLE land_reservation (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
cad_num text NOT NULL,
|
||||||
|
act_date date,
|
||||||
|
doc_url text
|
||||||
|
) ON COMMIT DROP
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Прод-форма: 9 строк развязки + 2 Энергостроителей + контрольные посторонние.
|
||||||
|
_SEED = (
|
||||||
|
[(f"66:41:0603006:{i}", date(2004, 7, 6), _URL_РАЗВЯЗКА) for i in range(9)]
|
||||||
|
+ [(f"66:41:0303004:{i}", date(2004, 7, 6), _URL_ЭНЕРГО) for i in range(2)]
|
||||||
|
+ [
|
||||||
|
# Чужой документ с той же датой — трогать НЕЛЬЗЯ: 06.07.2004 может быть
|
||||||
|
# и настоящей датой акта, сужение по doc_url именно для этого.
|
||||||
|
("66:41:0000001:1", date(2004, 7, 6), _URL_ЧУЖОЙ),
|
||||||
|
# Верные строки других документов — не должны сдвинуться.
|
||||||
|
("66:41:0502080:22", date(2015, 9, 29), _URL_ЧУЖОЙ),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _body() -> list[str]:
|
||||||
|
"""Тело миграции без комментариев, разбитое на выполнимые куски."""
|
||||||
|
raw = _MIGRATION.read_text(encoding="utf-8")
|
||||||
|
без_комментов = "\n".join(ln for ln in raw.splitlines() if not ln.lstrip().startswith("--"))
|
||||||
|
return [
|
||||||
|
chunk.strip()
|
||||||
|
for chunk in re.split(r";\s*(?:\n|$)", без_комментов)
|
||||||
|
if chunk.strip() and chunk.strip().upper() not in {"BEGIN", "COMMIT"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run() -> dict[tuple[str, date], int]:
|
||||||
|
session = sessionmaker(bind=create_engine(_dsn()))()
|
||||||
|
try:
|
||||||
|
session.execute(text(_TEMP))
|
||||||
|
for cad, d, url in _SEED:
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO land_reservation (cad_num, act_date, doc_url)" " VALUES (:c,:d,:u)"
|
||||||
|
),
|
||||||
|
{"c": cad, "d": d, "u": url},
|
||||||
|
)
|
||||||
|
for chunk in _body():
|
||||||
|
session.execute(text(chunk))
|
||||||
|
rows = session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT right(doc_url,32) AS h, act_date, count(*)"
|
||||||
|
" FROM land_reservation GROUP BY 1,2"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {(str(r[0]), r[1]): int(r[2]) for r in rows}
|
||||||
|
finally:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_genplan_dates_are_replaced_with_real_ones() -> None:
|
||||||
|
"""Головной: 9 + 2 строки получают дату своего постановления."""
|
||||||
|
res = _run()
|
||||||
|
assert res.get(("89adb28a3677e7df933e2d9ce0f205c8", date(2022, 5, 27))) == 9, res
|
||||||
|
assert res.get(("9b9d9a998f578db56315bb816fc2ebf5", date(2020, 2, 12))) == 2, res
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_2004_rows_left_in_target_documents() -> None:
|
||||||
|
"""Контроль полноты: в этих двух документах даты 2004 года не остаётся."""
|
||||||
|
res = _run()
|
||||||
|
for h in ("89adb28a3677e7df933e2d9ce0f205c8", "9b9d9a998f578db56315bb816fc2ebf5"):
|
||||||
|
assert res.get((h, date(2004, 7, 6))) is None, f"остались строки 2004 у {h}: {res}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_documents_with_same_date_are_untouched() -> None:
|
||||||
|
"""Контроль сужения: та же дата в ЧУЖОМ документе не трогается.
|
||||||
|
|
||||||
|
Без условия по doc_url миграция переписала бы любую строку с 06.07.2004,
|
||||||
|
включая те, где эта дата настоящая.
|
||||||
|
"""
|
||||||
|
res = _run()
|
||||||
|
assert res.get(("ffffffffffffffffffffffffffffffff", date(2004, 7, 6))) == 1, res
|
||||||
|
|
||||||
|
|
||||||
|
def test_correct_rows_do_not_move() -> None:
|
||||||
|
"""Контроль от переусердствования: верные даты остаются на месте."""
|
||||||
|
res = _run()
|
||||||
|
assert res.get(("ffffffffffffffffffffffffffffffff", date(2015, 9, 29))) == 1, res
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_is_idempotent() -> None:
|
||||||
|
"""Повторный запуск ничего не меняет — условие act_date='2004-07-06' не выполнится."""
|
||||||
|
session = sessionmaker(bind=create_engine(_dsn()))()
|
||||||
|
try:
|
||||||
|
session.execute(text(_TEMP))
|
||||||
|
for cad, d, url in _SEED:
|
||||||
|
session.execute(
|
||||||
|
text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"),
|
||||||
|
{"c": cad, "d": d, "u": url},
|
||||||
|
)
|
||||||
|
for _ in range(2):
|
||||||
|
for chunk in _body():
|
||||||
|
session.execute(text(chunk))
|
||||||
|
n = session.execute(
|
||||||
|
text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2022-05-27'")
|
||||||
|
).scalar()
|
||||||
|
assert n == 9, f"после двух прогонов строк с 2022-05-27: {n}"
|
||||||
|
finally:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_the_migration_rows_stay_wrong() -> None:
|
||||||
|
"""Контроль-двойник: БЕЗ прогона миграции строки остаются с датой Генплана.
|
||||||
|
|
||||||
|
Обязателен: без него тест неотличим от «оно и так было правильно».
|
||||||
|
"""
|
||||||
|
session = sessionmaker(bind=create_engine(_dsn()))()
|
||||||
|
try:
|
||||||
|
session.execute(text(_TEMP))
|
||||||
|
for cad, d, url in _SEED:
|
||||||
|
session.execute(
|
||||||
|
text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"),
|
||||||
|
{"c": cad, "d": d, "u": url},
|
||||||
|
)
|
||||||
|
# Миграцию НЕ прогоняем.
|
||||||
|
n = session.execute(
|
||||||
|
text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2004-07-06'")
|
||||||
|
).scalar()
|
||||||
|
assert n == 12, f"засеяно не то состояние — строк с датой Генплана: {n}"
|
||||||
|
assert (
|
||||||
|
session.execute(
|
||||||
|
text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2022-05-27'")
|
||||||
|
).scalar()
|
||||||
|
== 0
|
||||||
|
), "верная дата появилась без миграции — тест смотрел бы не туда"
|
||||||
|
finally:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
169
backend/tests/sql/test_2464_area_bin_unknown.py
Normal file
169
backend/tests/sql/test_2464_area_bin_unknown.py
Normal 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}"
|
||||||
42
data/sql/191_land_reservation_act_date_backfill.sql
Normal file
42
data/sql/191_land_reservation_act_date_backfill.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
-- 191: разовое исправление act_date у строк, куда уехала дата Генплана-2004 (#2464).
|
||||||
|
--
|
||||||
|
-- До #2981 `_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте.
|
||||||
|
-- «Сообщение о планируемом изъятии» открывается списком оснований, где первой
|
||||||
|
-- строкой стоит «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1
|
||||||
|
-- «Об утверждении Генерального плана города»». Эта дата и попадала в act_date.
|
||||||
|
--
|
||||||
|
-- #2981 чинит извлечение, но только ВПЕРЁД: UPSERT загрузчика заканчивается
|
||||||
|
-- `ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому следующий недельный прогон
|
||||||
|
-- (пятница 07:00) существующие строки не перезапишет. Без этой миграции 11 строк
|
||||||
|
-- остались бы с датой 2004 года навсегда.
|
||||||
|
--
|
||||||
|
-- Верные даты взяты не из догадки: оба PDF загружены с екатеринбург.рф и
|
||||||
|
-- распознаны тем же трактом, что использует загрузчик (ocr_pdf_text), и в обоих
|
||||||
|
-- настоящее основание — постановление Администрации города:
|
||||||
|
--
|
||||||
|
-- 89adb28a… развязка Базовый/Комсомольская/Сибирский тракт
|
||||||
|
-- «Постановление Администрации города Екатеринбурга
|
||||||
|
-- от 27.05.2022 № 1413 «Об утверждении проекта планировки…»» → 9 строк
|
||||||
|
-- 9b9d9a99… улица Энергостроителей
|
||||||
|
-- «Постановление Администрации города Екатеринбурга
|
||||||
|
-- от 12.02.2020 № 259 «Об утверждении проекта планировки…»» → 2 строки
|
||||||
|
--
|
||||||
|
-- Идемпотентна: условие `act_date = '2004-07-06'` при повторном запуске не
|
||||||
|
-- выполнится. Сужение по doc_url обязательно — без него UPDATE задел бы любую
|
||||||
|
-- будущую строку, где 06.07.2004 окажется настоящей датой акта.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
UPDATE land_reservation
|
||||||
|
SET act_date = DATE '2022-05-27'
|
||||||
|
WHERE act_date = DATE '2004-07-06'
|
||||||
|
AND doc_url LIKE '%89adb28a3677e7df933e2d9ce0f205c8';
|
||||||
|
|
||||||
|
UPDATE land_reservation
|
||||||
|
SET act_date = DATE '2020-02-12'
|
||||||
|
WHERE act_date = DATE '2004-07-06'
|
||||||
|
AND doc_url LIKE '%9b9d9a998f578db56315bb816fc2ebf5';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Add table
Reference in a new issue