Compare commits

..

4 commits

Author SHA1 Message Date
9e04086daf chore(ptica): перенумеровать миграцию 191 → 192 (#2986)
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 2m2s
CI / backend-tests (pull_request) Successful in 17m41s
Номер 191 занят PR #2984 (backfill act_date), который уходит в main
раньше. Обе ветки прошли CI со своим 191 — проверка идёт по голове
ветки и о занятости номера соседом узнать не может.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:41:27 +05:00
7d5ca247ca fix(ptica): ключ gisogd_permits — id документа на портале, а не (группа, номер) (#2986)
`UNIQUE (doc_group, doc_num)` вводился, чтобы склеивать ОДИН документ,
пришедший из двух схем портала. Замер 20.08.2026 показал, что задача,
ради которой ключ введён, почти отсутствует, а побочный эффект огромен:
docNum у ГИСОГД НЕ уникален — разрешение и изменения к нему носят один
номер.

    группа   документов   различных key   различных docNum   схлопывается
    DocRS         6098          6096            4305             1793
    DocRV         5419          5415            4969              450
    DocIZ          548           547             393              155

    общих docNum между схемами (DocRS): 2   ← ради этого ключ и вводился
    общих key    между схемами (DocRS): 2   ← те же два

На проде 9182 строки против 12 065 документов на портале — нет 23.9 %
реестра. Пример 66-06-06-2026: портал отдаёт два документа (key …719586 —
само разрешение, key …752293 — изменения к нему), а UPSERT с
предпочтением позднего date_reg оставлял только изменение. Так вытеснено
598 из 4320 строк РНС (13.8 %) — в §6 на месте разрешения показывается
изменение к нему, без признака подмены.

Ключ стал `UNIQUE (source_key)`: разделяет разрешение и изменения (разные
key) и по-прежнему склеивает настоящие межсхемные дубли (у них key
ОБЩИЙ — ровно 7 записей по всем группам). Дедуп перед сменой не нужен:
source_key на проде уже уникален (9182 из 9182, NOT NULL).

Заодно группа DocIZ добавлена в GROUP_CODE — её не было вовсе, 548
документов не грузились. CHECK расширен значением 'IZ'.

§6 сужена до РНС/РВЭ ЯВНО: агрегат обещает total_count = rs_count +
rv_count, а строки 'IZ' попадали бы в total и ни в один счётчик.
Показывать ли изменения отдельной строкой — вопрос продуктовый (#2986);
до его решения сужение стоит в запросе, а не держится на том, что таких
строк «пока нет».

Проверки:
- два гейта на лоадер (GROUP_CODE и цель ON CONFLICT) — БЕЗ базы,
  двусторонние: на origin/main дают конкретные неверные значения
  ({'DocRS','DocRV'} и старый ON CONFLICT в тексте запроса);
- гейт на §6 и контроль инварианта total = rs + rv на данных — красные
  на origin/main;
- герметичная репетиция миграции на временной копии: со старым ключом
  разрешение и изменение схлопываются в одну строку (и остаётся именно
  изменение — как на проде), после миграции живут раздельно; межсхемный
  дубль по-прежнему склеивается; CHECK принимает 'IZ' и отвергает мусор.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:41:27 +05:00
b975451b53 fix(ptica): backfill act_date у 11 строк, куда уехала дата Генплана-2004 (#2464)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 51s
Deploy / build-backend (push) Successful in 51s
Deploy / deploy (push) Successful in 1m17s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
Продолжение #2981. Часть эпика #2464.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:37:42 +00:00
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
3 changed files with 258 additions and 0 deletions

View file

@ -144,6 +144,16 @@ 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
# ── #2986: бизнес-ключ gisogd_permits по source_key (миграция 191) ────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию таблицы в прод-форме (со СТАРЫМ

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;