Merge pull request 'ПТИЦА: у изъятий на участке появляется номер постановления, а таблица не удваивается' (#3568) from fix/ptica-reservation-act-number into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
This commit is contained in:
commit
d9a5bfe71f
5 changed files with 310 additions and 22 deletions
|
|
@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
|
|||
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
|
||||
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
||||
|
||||
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
|
||||
_RE_ACT_NUMBER = re.compile(
|
||||
r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
||||
re.UNICODE | re.IGNORECASE,
|
||||
)
|
||||
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
|
||||
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
|
||||
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
|
||||
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12».
|
||||
_ACT_NUM = r"(\d+(?:\s*[-–]\s*[А-Я]{1,3})?)(?![\w/\-–])"
|
||||
_RE_ACT_NUMBER = re.compile(rf"№\s*{_ACT_NUM}")
|
||||
# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY».
|
||||
_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}")
|
||||
_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"№\s*{_ACT_NUM}\s*$")
|
||||
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
||||
|
||||
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
|
||||
|
|
@ -233,7 +237,9 @@ def extract_izyatie_records(
|
|||
normalized = _normalize_ocr_text(ocr_text)
|
||||
|
||||
# Реквизиты акта из заголовка или текста.
|
||||
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
|
||||
act_number = _extract_act_number(doc_title) or _extract_act_number(
|
||||
normalized, require_act_context=True
|
||||
)
|
||||
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
||||
normalized, require_act_context=True
|
||||
)
|
||||
|
|
@ -289,9 +295,26 @@ def extract_izyatie_records(
|
|||
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_act_number(text: str) -> str | None:
|
||||
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
|
||||
m = _RE_ACT_NUMBER.search(text)
|
||||
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
|
||||
"""Извлекает номер акта.
|
||||
|
||||
Без require_act_context (заголовок) — первое «№ N» в тексте.
|
||||
|
||||
С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату
|
||||
выбирает `_extract_act_date`: «№» вплотную после даты, иначе вплотную перед
|
||||
ней. Первое «№» в теле — это «Решение Думы № 60/1» (Генплан) или «Приказ
|
||||
Министерства № 746-П», а не основание изъятия. Номера рядом с датой нет —
|
||||
None, а не номер соседнего документа.
|
||||
"""
|
||||
if not require_act_context:
|
||||
m = _RE_ACT_NUMBER.search(text)
|
||||
else:
|
||||
date_m = _act_date_match(text, require_act_context=True)
|
||||
if date_m is None:
|
||||
return None
|
||||
m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or (
|
||||
_RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start())
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
return re.sub(r"\s+", "", m.group(1))
|
||||
|
|
@ -303,6 +326,17 @@ def _act_context_matches(text: str, pos: int) -> bool:
|
|||
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
|
||||
|
||||
|
||||
def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None:
|
||||
"""Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен)."""
|
||||
for m in _RE_ACT_DATE.finditer(text):
|
||||
if require_act_context and not _act_context_matches(text, m.start()):
|
||||
continue
|
||||
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
||||
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
||||
|
||||
|
|
@ -315,18 +349,10 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
|
|||
даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать
|
||||
актуальные изъятия, ни сверить срок.
|
||||
"""
|
||||
for m in _RE_ACT_DATE.finditer(text):
|
||||
if require_act_context and not _act_context_matches(text, m.start()):
|
||||
continue
|
||||
day, month, year = m.group(1), m.group(2), m.group(3)
|
||||
try:
|
||||
# Валидируем диапазоны.
|
||||
d, mo, y = int(day), int(month), int(year)
|
||||
except ValueError:
|
||||
continue
|
||||
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
||||
return f"{y:04d}-{mo:02d}-{d:02d}"
|
||||
return None
|
||||
m = _act_date_match(text, require_act_context=require_act_context)
|
||||
if m is None:
|
||||
return None
|
||||
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
|
||||
|
||||
|
||||
def _extract_purpose(text: str) -> str | None:
|
||||
|
|
|
|||
|
|
@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
|
|||
|
||||
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
|
||||
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
||||
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
|
||||
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
|
||||
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
|
||||
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
|
||||
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
|
||||
_RE_ACT_NUMBER = re.compile(
|
||||
r"№\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
||||
r"№\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
|
||||
re.UNICODE,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,25 @@ _UPSERT_NO_ACT_SQL = text(
|
|||
)
|
||||
|
||||
|
||||
# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982).
|
||||
#
|
||||
# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая
|
||||
# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке
|
||||
# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон
|
||||
# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие
|
||||
# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит
|
||||
# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же
|
||||
# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается.
|
||||
_DELETE_STALE_PARSE_SQL = text(
|
||||
"""
|
||||
DELETE FROM land_reservation
|
||||
WHERE cad_num = CAST(:cad_num AS text)
|
||||
AND doc_url = CAST(:doc_url AS text)
|
||||
AND act_number IS DISTINCT FROM CAST(:act_number AS text)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
||||
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
||||
count = 0
|
||||
|
|
@ -117,6 +136,7 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
|||
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT per-row
|
||||
db.execute(_DELETE_STALE_PARSE_SQL, row)
|
||||
db.execute(upsert_sql, row)
|
||||
count += 1
|
||||
except Exception as exc:
|
||||
|
|
|
|||
103
backend/tests/services/scrapers/test_2982_act_number.py
Normal file
103
backend/tests/services/scrapers/test_2982_act_number.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982).
|
||||
|
||||
Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У
|
||||
постановлений Администрации города его нет, поэтому на проде `act_number` пуст у
|
||||
всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0).
|
||||
|
||||
Фрагменты — дословный OCR пяти прод-документов из test_2464_act_date_citation
|
||||
(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера — из самих PDF:
|
||||
1413, 259, 2687, 863, 1504. Помехи в тех же текстах — «Решение Думы № 60/1»
|
||||
(Генплан) и «Приказ Министерства № 746-П»: их номер браться не должен.
|
||||
|
||||
Тест зовёт `extract_izyatie_records` — путь загрузчика — и краснеет на origin/main
|
||||
неверным значением (None вместо номера), а не ошибкой сигнатуры.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.izyatie_ocr import extract_izyatie_records
|
||||
from app.services.scrapers.page_reservation_parser import extract_reservations
|
||||
from tests.services.scrapers.test_2464_act_date_citation import (
|
||||
ДОК_ПЕРЕМЕШАННЫЙ,
|
||||
ДОК_ПЕРЕМЕШАННЫЙ_2,
|
||||
ДОК_ПРЯМОЙ,
|
||||
ДОК_РАЗВЯЗКА,
|
||||
ДОК_ЭНЕРГОСТРОИТЕЛЕЙ,
|
||||
ЗАГОЛОВОК,
|
||||
КАД,
|
||||
)
|
||||
|
||||
ПЯТЬ_ДОКУМЕНТОВ = [
|
||||
pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"),
|
||||
pytest.param(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"),
|
||||
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"),
|
||||
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"),
|
||||
pytest.param(ДОК_ПРЯМОЙ, "1504", "2019-06-24", id="татищева"),
|
||||
]
|
||||
|
||||
|
||||
def _запись(текст: str) -> dict:
|
||||
записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
|
||||
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
|
||||
return записи[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
||||
def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None:
|
||||
"""Головной: номер постановления без суффикса извлекается, и это номер того же акта,
|
||||
чья дата стоит в act_date, — а не Решения Думы или приказа Министерства."""
|
||||
запись = _запись(текст)
|
||||
assert запись["act_number"] == номер
|
||||
assert запись["act_date"] == дата
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("текст", "номер", "_дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
||||
def test_page_parser_takes_the_same_numbers(текст: str, номер: str, _дата: str) -> None:
|
||||
"""Второе место того же дефекта — page_reservation_parser (pravo.gov66).
|
||||
|
||||
Там номер — первое «№» в тексте. В двух документах первыми идут «№ 60/1» и
|
||||
«№ 746-П»: их нельзя ни взять, ни обрезать до «60»/«746».
|
||||
"""
|
||||
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер
|
||||
|
||||
|
||||
def test_citations_alone_give_no_number() -> None:
|
||||
"""Контроль: без постановления — None, а не номер Генплана или приказа."""
|
||||
текст = (
|
||||
"Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n"
|
||||
"Приказ Министерства строительства и развития инфраструктуры Свердловской "
|
||||
"области от 30.12.2021 № 746-П"
|
||||
)
|
||||
assert _запись(текст)["act_number"] is None
|
||||
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None
|
||||
|
||||
|
||||
def test_number_belongs_to_the_act_whose_date_is_taken() -> None:
|
||||
"""Контроль связки: номер берётся у того же акта, что и дата, а не первое «№».
|
||||
|
||||
Тот же документ «развязка», но у приказа Министерства номер без суффикса.
|
||||
Первое подходящее «№» в тексте — «746», дата при этом у постановления № 1413:
|
||||
в строке оказалась бы пара реквизитов от двух разных документов.
|
||||
"""
|
||||
текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746")
|
||||
запись = _запись(текст)
|
||||
assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413")
|
||||
|
||||
|
||||
def test_page_parser_prefers_own_number_over_cited_regional_act() -> None:
|
||||
"""Контроль порядка: собственный номер в шапке без суффикса не пропускается ради
|
||||
следующего «№ …-ПП» — номера цитируемого постановления Правительства."""
|
||||
текст = (
|
||||
"ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n"
|
||||
"В соответствии с постановлением Правительства Свердловской области "
|
||||
f"от 26.02.2020 № 100-ПП\n{КАД}"
|
||||
)
|
||||
assert extract_reservations(текст)[0].act_number == "1234"
|
||||
|
||||
|
||||
def test_regional_suffix_still_extracted() -> None:
|
||||
"""Контроль от переусердствования: областной номер с суффиксом не потерян."""
|
||||
assert _запись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП"
|
||||
assert _запись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП"
|
||||
134
backend/tests/sql/test_2982_land_reservation_reparse.py
Normal file
134
backend/tests/sql/test_2982_land_reservation_reparse.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Починка разбора номера не удваивает land_reservation (#2982).
|
||||
|
||||
act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На
|
||||
проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться,
|
||||
строка (участок, «1413») с (участок, NULL) не конфликтует — без очистки прогон
|
||||
кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды.
|
||||
|
||||
Проверяется `_upsert_records` загрузчика целиком на временной таблице той же
|
||||
формы: боевые данные не читаются и не меняются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.workers.tasks.izyatie_ocr_ingest import _upsert_records
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
_TABLE = """
|
||||
CREATE TEMP TABLE land_reservation (
|
||||
id bigserial PRIMARY KEY,
|
||||
cad_num text NOT NULL,
|
||||
reservation_kind text NOT NULL,
|
||||
basis_act text NOT NULL,
|
||||
act_number text,
|
||||
act_date date,
|
||||
purpose text,
|
||||
doc_url text,
|
||||
source text NOT NULL DEFAULT 'page_pdf',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
raw_excerpt text,
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number)
|
||||
) ON COMMIT DROP;
|
||||
"""
|
||||
|
||||
КАД = "66:41:0303004:22"
|
||||
ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
|
||||
|
||||
|
||||
def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict:
|
||||
return {
|
||||
"cad_num": cad,
|
||||
"reservation_kind": "изъятие",
|
||||
"basis_act": "Сообщение о планируемом изъятии",
|
||||
"act_number": act,
|
||||
"act_date": "2022-05-27",
|
||||
"purpose": None,
|
||||
"doc_url": url,
|
||||
"source": "izyatie_ekb_ocr",
|
||||
"raw_excerpt": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(_dsn())
|
||||
session = sessionmaker(bind=engine)()
|
||||
try:
|
||||
session.execute(text(_TABLE))
|
||||
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
|
||||
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _rows(db) -> list[tuple[str, str | None, str]]:
|
||||
return [
|
||||
(r[0], r[1], r[2])
|
||||
for r in db.execute(
|
||||
text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3")
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None:
|
||||
"""Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером."""
|
||||
_upsert_records(db, [_row(act=None)])
|
||||
assert _rows(db) == [(КАД, None, ДОК)]
|
||||
|
||||
assert _upsert_records(db, [_row(act="1413")]) == 1
|
||||
assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой"
|
||||
|
||||
_upsert_records(db, [_row(act="1413")])
|
||||
assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен"
|
||||
|
||||
|
||||
def test_same_parcel_in_another_document_is_kept(db) -> None:
|
||||
"""Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем."""
|
||||
другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
|
||||
_upsert_records(db, [_row(act="259", url=другой)])
|
||||
_upsert_records(db, [_row(act=None)])
|
||||
_upsert_records(db, [_row(act="1413")])
|
||||
assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)]
|
||||
|
||||
|
||||
def test_other_parcels_of_same_document_are_kept(db) -> None:
|
||||
"""Контроль: чистка идёт по участку, а не по всему документу."""
|
||||
_upsert_records(db, [_row(cad="66:41:0303004:23", act=None)])
|
||||
_upsert_records(db, [_row(act="1413")])
|
||||
assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]
|
||||
Loading…
Add table
Reference in a new issue