From 668f8c6ffb911c8d69b280a78cff234e02914dc5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 21:49:52 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20backfill=20act=5Fdate=20=D1=83=20?= =?UTF-8?q?11=20=D1=81=D1=82=D1=80=D0=BE=D0=BA,=20=D0=BA=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=20=D1=83=D0=B5=D1=85=D0=B0=D0=BB=D0=B0=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20=D0=93=D0=B5=D0=BD=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0-2004?= =?UTF-8?q?=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit заканчивается `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 --- backend/tests/skip_allowlist.txt | 10 + .../tests/sql/test_2464_act_date_backfill.py | 206 ++++++++++++++++++ ...191_land_reservation_act_date_backfill.sql | 42 ++++ 3 files changed, 258 insertions(+) create mode 100644 backend/tests/sql/test_2464_act_date_backfill.py create mode 100644 data/sql/191_land_reservation_act_date_backfill.sql diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 4c6f7706..30bd5ad3 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -144,3 +144,13 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as 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 diff --git a/backend/tests/sql/test_2464_act_date_backfill.py b/backend/tests/sql/test_2464_act_date_backfill.py new file mode 100644 index 00000000..bc6a0311 --- /dev/null +++ b/backend/tests/sql/test_2464_act_date_backfill.py @@ -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() diff --git a/data/sql/191_land_reservation_act_date_backfill.sql b/data/sql/191_land_reservation_act_date_backfill.sql new file mode 100644 index 00000000..71dc7637 --- /dev/null +++ b/data/sql/191_land_reservation_act_date_backfill.sql @@ -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; -- 2.45.3