All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
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 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после #3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки на нетронутых строках (длинные assert-сообщения и т.п.). Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего, кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих проектах, повторный `format --check` — 1001 файл без изменений (идемпотентно), compileall чистый. Refs #2864
204 lines
8.7 KiB
Python
204 lines
8.7 KiB
Python
"""Миграция 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()
|