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>
This commit is contained in:
bot-backend 2026-08-20 21:49:52 +05:00
parent 91a5207641
commit 668f8c6ffb
3 changed files with 258 additions and 0 deletions

View file

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

View 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()

View 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;