gendesign/tradein-mvp/backend/app/services/gar_flats_loader.py
lekss361 579536b4ad
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m31s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 54s
feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only (#2063)
2026-06-28 15:50:07 +00:00

673 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ГАР/ФИАС loader: «квартир на дом» (знаменатель «доли квартир дома в продаже»).
Парсит региональный ГАР-дамп (новый формат, по-региональная папка, напр. `66/`) и
наполняет таблицу `gar_house_flats` числом помещений-квартир под каждым домом, затем
матчит к `houses` по НОРМАЛИЗОВАННОМУ адресу (`tradein_normalize_short_addr`) и
проставляет `houses.gar_flat_count` (мигр. 143).
Формат ГАР (данные — в АТРИБУТАХ элементов, не в тексте):
- AS_ADDR_OBJ_*.XML — адресные объекты (регион/город/улица).
OBJECTID, OBJECTGUID, NAME, TYPENAME, LEVEL, ISACTUAL, ISACTIVE.
- AS_HOUSES_*.XML — дома.
OBJECTID, OBJECTGUID, HOUSENUM, ADDNUM1/2, HOUSETYPE, ADDTYPE1/2, ISACTUAL, ISACTIVE.
- AS_APARTMENTS_*.XML — помещения/квартиры.
OBJECTID, NUMBER, APARTTYPE, ISACTUAL, ISACTIVE.
- AS_MUN_HIERARCHY_*.XML — иерархия (приоритетна; ADM — fallback).
OBJECTID, PARENTOBJID, ISACTIVE — объект OBJECTID лежит под PARENTOBJID.
Подсчёт квартир: число OBJECTID помещений, чей PARENTOBJID в иерархии == OBJECTID дома
(помещения — прямые дети дома). Адрес дома: parent дома в иерархии = улица/адр-объект;
поднимаемся по parent'ам до города/региона, собирая «<TYPENAME NAME>» сегменты, + номер дома.
Память: ВСЕ файлы (особенно AS_APARTMENTS/AS_MUN_HIERARCHY для региона 66) — многогигабайтные.
Парсим стримингом через `lxml.etree.iterparse` с очисткой обработанных элементов + их
предыдущих сиблингов (никогда не грузим дерево целиком). Единственная крупная структура в
памяти — set OBJECTID активных помещений (≈десятки МБ на регион).
Соответствие нормализации: `norm_address` считается тем же `tradein_normalize_short_addr`,
что применяется к адресам `houses` при матче (мигр. 063), но — для устойчивости к
не-ЕКБ-населённым пунктам — поверх «улица + номер дома» строки (эквивалентно
fn(full_address) для ЕКБ, где fn срезает регион/город). full_address хранится отдельно как
человекочитаемая provenance-цепочка.
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
import logging
import os
import time
from collections import defaultdict
from collections.abc import Iterator
from dataclasses import dataclass, field
from lxml import etree
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
ADDR_OBJ_PREFIX = "AS_ADDR_OBJ_"
HOUSES_PREFIX = "AS_HOUSES_"
APARTMENTS_PREFIX = "AS_APARTMENTS_"
MUN_HIER_PREFIX = "AS_MUN_HIERARCHY_"
ADM_HIER_PREFIX = "AS_ADM_HIERARCHY_" # fallback, если MUN отсутствует
DEFAULT_BATCH_SIZE = 2000
# ADDTYPE дома (AS_ADDHOUSE_TYPES): 1=Корпус, 2=Строение, 3=Сооружение. Рендер — аббревиатура.
_ADDTYPE_ABBR: dict[str, str] = {"1": "к", "2": "стр", "3": "соор"}
# LEVEL/типы региона-уровня: рендерим «<NAME> <TYPENAME>» (Свердловская обл), остальное —
# «<TYPENAME> <NAME>» (ул Шаумяна / г Екатеринбург). Косметика full_address (для людей);
# на матч не влияет (norm_address считается из «улица + номер»).
_REGION_LIKE_TYPES = {"обл", "область", "край", "респ", "республика", "ао", "аобл"}
# ─────────────────────────────────────────────────────────────────────────────
# In-memory модели
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class AddrObj:
object_id: int
guid: str
name: str
typename: str
level: str
@dataclass(slots=True)
class GarHouse:
object_id: int
guid: str
housenum: str
addnum1: str
addnum2: str
addtype1: str
addtype2: str
@dataclass(slots=True)
class GarHouseRow:
"""Строка-приёмник для gar_house_flats (norm_address считает SQL-fn в INSERT)."""
house_guid: str
object_id: int
region_code: str
flat_count: int
full_address: str
street_name: str | None
house_num: str
norm_src: str # вход для tradein_normalize_short_addr (улица + номер)
gar_version: str
def to_params(self) -> dict[str, object]:
return {
"house_guid": self.house_guid,
"object_id": self.object_id,
"region_code": self.region_code,
"flat_count": self.flat_count,
"full_address": self.full_address,
"street_name": self.street_name,
"house_num": self.house_num,
"norm_src": self.norm_src,
"gar_version": self.gar_version,
}
@dataclass(slots=True)
class GarParseStats:
addr_objects: int = 0
houses: int = 0
apartments: int = 0 # суммарно активных помещений (= sum(flat_count))
rows: int = 0 # построенных строк gar_house_flats
# ─────────────────────────────────────────────────────────────────────────────
# Поиск файлов
# ─────────────────────────────────────────────────────────────────────────────
def _find_files(dir_path: str, prefix: str) -> list[str]:
"""Файлы dir_path, чьё имя начинается с prefix и кончается .xml (case-insensitive)."""
out: list[str] = []
for name in sorted(os.listdir(dir_path)):
upper = name.upper()
if upper.startswith(prefix.upper()) and upper.endswith(".XML"):
out.append(os.path.join(dir_path, name))
return out
# ─────────────────────────────────────────────────────────────────────────────
# Стриминговый парсер (lxml iterparse + очистка)
# ─────────────────────────────────────────────────────────────────────────────
def _local_name(tag: object) -> str:
"""Локальное имя тега без namespace; '' для не-элементов (комментарии/PI)."""
if not isinstance(tag, str):
return ""
return tag.rsplit("}", 1)[-1]
def _stream_rows(path: str, marker_attr: str) -> Iterator[etree._Element]:
"""Yield row-элементов файла, у которых есть атрибут marker_attr, освобождая память.
После выдачи элемента (caller обязан прочитать атрибуты СРАЗУ в теле цикла) элемент
очищается, а его уже обработанные предыдущие сиблинги удаляются из дерева — так
память не растёт на многогигабайтных файлах. `huge_tree=True` снимает security-лимит
libxml2 на размер; `recover=True` терпит мелкие дефекты XML.
"""
context = etree.iterparse(
path, events=("end",), recover=True, huge_tree=True, resolve_entities=False
)
for _event, elem in context:
is_elem = isinstance(elem.tag, str)
if is_elem and marker_attr in elem.attrib:
yield elem
# Чистим текущий элемент и срезаем предшествующие сиблинги (bounded memory).
elem.clear()
parent = elem.getparent()
if parent is not None:
while elem.getprevious() is not None:
del parent[0]
del context
def _is_active(elem: etree._Element, *, need_actual: bool) -> bool:
"""ISACTIVE='1' (+ опц. ISACTUAL='1'). Отсутствующий атрибут считаем НЕ активным."""
if elem.get("ISACTIVE") != "1":
return False
if need_actual and elem.get("ISACTUAL") != "1":
return False
return True
def _to_int(value: str | None) -> int | None:
if value is None:
return None
value = value.strip()
if not value:
return None
try:
return int(value)
except ValueError:
return None
def parse_addr_objects(paths: list[str]) -> dict[int, AddrObj]:
"""OBJECTID → AddrObj по активным (ISACTUAL=1 AND ISACTIVE=1) адресным объектам."""
out: dict[int, AddrObj] = {}
for path in paths:
for elem in _stream_rows(path, "OBJECTGUID"):
if not _is_active(elem, need_actual=True):
continue
oid = _to_int(elem.get("OBJECTID"))
guid = elem.get("OBJECTGUID")
if oid is None or not guid:
continue
out[oid] = AddrObj(
object_id=oid,
guid=guid,
name=(elem.get("NAME") or "").strip(),
typename=(elem.get("TYPENAME") or "").strip(),
level=(elem.get("LEVEL") or "").strip(),
)
return out
def parse_houses(paths: list[str]) -> dict[int, GarHouse]:
"""OBJECTID → GarHouse по активным (ISACTUAL=1 AND ISACTIVE=1) домам."""
out: dict[int, GarHouse] = {}
for path in paths:
for elem in _stream_rows(path, "HOUSENUM"):
if not _is_active(elem, need_actual=True):
continue
oid = _to_int(elem.get("OBJECTID"))
guid = elem.get("OBJECTGUID")
if oid is None or not guid:
continue
out[oid] = GarHouse(
object_id=oid,
guid=guid,
housenum=(elem.get("HOUSENUM") or "").strip(),
addnum1=(elem.get("ADDNUM1") or "").strip(),
addnum2=(elem.get("ADDNUM2") or "").strip(),
addtype1=(elem.get("ADDTYPE1") or "").strip(),
addtype2=(elem.get("ADDTYPE2") or "").strip(),
)
return out
def parse_apartment_ids(paths: list[str]) -> set[int]:
"""Set OBJECTID активных (ISACTUAL=1 AND ISACTIVE=1) помещений/квартир.
Тип помещения (APARTTYPE) не фильтруем — знаменатель = все активные помещения дома
(квартиры + нежилые); это сознательное приближение «всего квартир в доме».
"""
out: set[int] = set()
for path in paths:
for elem in _stream_rows(path, "OBJECTID"):
if not _is_active(elem, need_actual=True):
continue
oid = _to_int(elem.get("OBJECTID"))
if oid is not None:
out.add(oid)
return out
def parse_hierarchy(
paths: list[str],
apartment_ids: set[int],
house_ids: set[int],
addr_ids: set[int],
) -> tuple[dict[int, int], dict[int, int]]:
"""Единый стриминговый проход по иерархии (только активные ISACTIVE=1).
Возвращает (apt_count, child_to_parent):
- apt_count[parent_house_objectid] = число помещений-детей этого дома. Считается
проверкой членства OBJECTID в apartment_ids; каждый помещение учитывается ОДИН раз
(discard из set после учёта — защита от дубль-строк иерархии + bounded memory).
- child_to_parent[oid] = PARENTOBJID, но ТОЛЬКО для домов и адр-объектов (нужно для
сборки адреса) — иерархия региона целиком в память не грузится.
"""
apt_count: dict[int, int] = defaultdict(int)
child_to_parent: dict[int, int] = {}
for path in paths:
for elem in _stream_rows(path, "PARENTOBJID"):
if elem.get("ISACTIVE") != "1":
continue
oid = _to_int(elem.get("OBJECTID"))
pid = _to_int(elem.get("PARENTOBJID"))
if oid is None or pid is None:
continue
if oid in apartment_ids:
apt_count[pid] += 1
apartment_ids.discard(oid)
if oid in house_ids or oid in addr_ids:
child_to_parent[oid] = pid
return dict(apt_count), child_to_parent
# ─────────────────────────────────────────────────────────────────────────────
# Сборка адреса
# ─────────────────────────────────────────────────────────────────────────────
def _render_segment(ao: AddrObj) -> str:
"""Косметический рендер адр-сегмента для full_address."""
name = ao.name.strip()
typ = ao.typename.strip()
if not typ:
return name
if typ.lower() in _REGION_LIKE_TYPES:
return f"{name} {typ}".strip()
return f"{typ} {name}".strip()
def render_house_num(house: GarHouse) -> str:
"""Номер дома: HOUSENUM (+ корпус/строение из ADDTYPE/ADDNUM, если есть)."""
parts: list[str] = []
if house.housenum:
parts.append(house.housenum)
if house.addnum1:
abbr = _ADDTYPE_ABBR.get(house.addtype1, "")
parts.append(f"{abbr}{house.addnum1}".strip())
if house.addnum2:
abbr = _ADDTYPE_ABBR.get(house.addtype2, "")
parts.append(f"{abbr}{house.addnum2}".strip())
return " ".join(p for p in parts if p)
def _walk_segments(
start_parent: int | None,
addr_objs: dict[int, AddrObj],
child_to_parent: dict[int, int],
) -> list[AddrObj]:
"""Сегменты адр-объектов снизу вверх (улица → … → регион), начиная с start_parent."""
segments: list[AddrObj] = []
visited: set[int] = set()
cur = start_parent
while cur is not None and cur in addr_objs and cur not in visited:
visited.add(cur)
segments.append(addr_objs[cur])
cur = child_to_parent.get(cur)
return segments
def assemble_house_address(
house: GarHouse,
addr_objs: dict[int, AddrObj],
child_to_parent: dict[int, int],
) -> tuple[str, str | None, str]:
"""(full_address, street_name, house_num) для дома, поднимаясь по иерархии parent'ов.
street_name = непосредственный адр-родитель дома (обычно улица), «<TYPENAME> <NAME>».
full_address = регион…город…улица (косметически) + номер дома.
"""
house_num = render_house_num(house)
parent_id = child_to_parent.get(house.object_id)
segments = _walk_segments(parent_id, addr_objs, child_to_parent)
street_name = _render_segment(segments[0]) if segments else None
# full_address: разворачиваем (регион → улица) + номер.
chain = [_render_segment(s) for s in reversed(segments)]
if house_num:
chain.append(house_num)
full_address = ", ".join(c for c in chain if c)
return full_address, street_name, house_num
def _norm_src(street_name: str | None, house_num: str, full_address: str) -> str:
"""Вход для tradein_normalize_short_addr: «улица, номер» (устойчиво к не-ЕКБ нас.пунктам).
Падаем на full_address, если улица не определилась (редкий дом без адр-родителя).
"""
if street_name and house_num:
return f"{street_name}, {house_num}"
if street_name:
return street_name
return full_address
def build_gar_rows(
addr_objs: dict[int, AddrObj],
houses: dict[int, GarHouse],
apt_count: dict[int, int],
child_to_parent: dict[int, int],
region_code: str,
gar_version: str,
) -> list[GarHouseRow]:
"""Строки gar_house_flats: на каждый дом — адрес + flat_count (0, если нет помещений)."""
rows: list[GarHouseRow] = []
for oid, house in houses.items():
full_address, street_name, house_num = assemble_house_address(
house, addr_objs, child_to_parent
)
rows.append(
GarHouseRow(
house_guid=house.guid,
object_id=oid,
region_code=region_code,
flat_count=apt_count.get(oid, 0),
full_address=full_address,
street_name=street_name,
house_num=house_num,
norm_src=_norm_src(street_name, house_num, full_address),
gar_version=gar_version,
)
)
return rows
def parse_gar_region(
dir_path: str, region_code: str, gar_version: str
) -> tuple[list[GarHouseRow], GarParseStats]:
"""Парсит распакованный ГАР-каталог региона → (строки gar_house_flats, статистика).
Чистая (без БД) часть — тестируется на синтетических фикстурах.
"""
if not os.path.isdir(dir_path):
raise NotADirectoryError(f"GAR dir not found: {dir_path}")
addr_paths = _find_files(dir_path, ADDR_OBJ_PREFIX)
house_paths = _find_files(dir_path, HOUSES_PREFIX)
apt_paths = _find_files(dir_path, APARTMENTS_PREFIX)
hier_paths = _find_files(dir_path, MUN_HIER_PREFIX)
if not hier_paths:
hier_paths = _find_files(dir_path, ADM_HIER_PREFIX)
if hier_paths:
logger.warning("GAR %s: AS_MUN_HIERARCHY отсутствует, использую ADM fallback", dir_path)
if not house_paths:
raise FileNotFoundError(f"AS_HOUSES_*.XML не найдены в {dir_path}")
if not hier_paths:
raise FileNotFoundError(f"AS_MUN_HIERARCHY/ADM_HIERARCHY_*.XML не найдены в {dir_path}")
logger.info(
"GAR parse %s: addr=%d houses=%d apartments=%d hierarchy=%d файлов",
dir_path,
len(addr_paths),
len(house_paths),
len(apt_paths),
len(hier_paths),
)
addr_objs = parse_addr_objects(addr_paths)
houses = parse_houses(house_paths)
apartment_ids = parse_apartment_ids(apt_paths)
apartments_total = len(apartment_ids)
logger.info(
"GAR parse %s: addr_obj=%d houses=%d active_apartments=%d",
dir_path,
len(addr_objs),
len(houses),
apartments_total,
)
apt_count, child_to_parent = parse_hierarchy(
hier_paths, apartment_ids, set(houses.keys()), set(addr_objs.keys())
)
rows = build_gar_rows(addr_objs, houses, apt_count, child_to_parent, region_code, gar_version)
counted = sum(r.flat_count for r in rows)
stats = GarParseStats(
addr_objects=len(addr_objs),
houses=len(houses),
apartments=counted,
rows=len(rows),
)
logger.info(
"GAR parse %s: построено строк=%d, помещений учтено под домами=%d (из %d активных)",
dir_path,
stats.rows,
counted,
apartments_total,
)
return rows, stats
# ─────────────────────────────────────────────────────────────────────────────
# Upsert в gar_house_flats
# ─────────────────────────────────────────────────────────────────────────────
# norm_address считает SQL-fn прямо в INSERT (эквивалентно «UPDATE после insert»), вход —
# «улица, номер» (:norm_src). CAST(:norm_src AS text) — psycopg v3 discipline (не :x::text).
_INSERT_SQL = text(
"""
INSERT INTO gar_house_flats (
house_guid, object_id, region_code, flat_count,
full_address, norm_address, street_name, house_num, gar_version
)
VALUES (
:house_guid, :object_id, :region_code, :flat_count,
:full_address,
tradein_normalize_short_addr(CAST(:norm_src AS text)),
:street_name, :house_num, :gar_version
)
ON CONFLICT (house_guid) DO UPDATE SET
object_id = EXCLUDED.object_id,
region_code = EXCLUDED.region_code,
flat_count = EXCLUDED.flat_count,
full_address = EXCLUDED.full_address,
norm_address = EXCLUDED.norm_address,
street_name = EXCLUDED.street_name,
house_num = EXCLUDED.house_num,
loaded_at = now(),
gar_version = EXCLUDED.gar_version
"""
)
def _chunks(items: list[GarHouseRow], size: int) -> Iterator[list[GarHouseRow]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def upsert_gar_houses(
db: Session, rows: list[GarHouseRow], *, batch_size: int = DEFAULT_BATCH_SIZE
) -> int:
"""Батчевый UPSERT строк в gar_house_flats. НЕ коммитит (коммитит caller).
SAVEPOINT (`begin_nested`) на КАЖДЫЙ батч: сбойный батч откатывается изолированно, не
срывая всю транзакцию и не врёт счётчику. ON CONFLICT (house_guid) → идемпотентно.
"""
upserted = 0
failed_batches = 0
for batch in _chunks(rows, batch_size):
params = [r.to_params() for r in batch]
try:
with db.begin_nested():
db.execute(_INSERT_SQL, params)
upserted += len(batch)
except Exception:
failed_batches += 1
logger.warning(
"gar upsert: батч из %d строк сбойнул (пропущен)", len(batch), exc_info=True
)
if failed_batches:
logger.warning("gar upsert: сбойных батчей=%d", failed_batches)
logger.info("gar upsert: upsert'нуто строк=%d", upserted)
return upserted
# ─────────────────────────────────────────────────────────────────────────────
# Матчер ГАР → houses
# ─────────────────────────────────────────────────────────────────────────────
# Идемпотентный set-based UPDATE по КАНОНИЧЕСКОМУ ключу адреса tradein_canon_addr
# (мигр. 144), а не по точному равенству нормализованного адреса: tradein_normalize_short_addr
# почти ничего не канонизирует → houses-адрес «ул. Шаумяна,20» никогда не равен ГАР
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
#
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
# канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него
# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
_MATCH_SQL = text(
"""
WITH gar_pick AS (
SELECT DISTINCT ON (canon)
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
FROM gar_house_flats
WHERE flat_count > 0
AND tradein_canon_addr(norm_address) <> ''
AND (
CAST(:region AS text) IS NULL
OR region_code = CAST(:region AS text)
)
AND (
CAST(:city AS text) IS NULL
OR full_address ILIKE '%' || CAST(:city AS text) || '%'
)
ORDER BY canon, flat_count DESC, house_guid
)
UPDATE houses h
SET gar_house_guid = gp.house_guid,
gar_flat_count = gp.flat_count,
gar_matched_at = now(),
gar_match_method = 'canon_addr'
FROM gar_pick gp
WHERE tradein_canon_addr(
COALESCE(h.short_address, h.full_address, h.address)
) = gp.canon
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
"""
)
def match_houses_to_gar(
db: Session,
*,
region_code: str | None = None,
city_filter: str | None = "Екатеринбург",
) -> int:
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE —
защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
за пределами ЕКБ — future work). Возвращает число обновлённых домов. Идемпотентно
(plain UPDATE, IS DISTINCT FROM gate).
"""
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount
logger.info(
"gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)",
matched,
region_code,
city_filter,
)
return matched
# ─────────────────────────────────────────────────────────────────────────────
# Orchestration
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class GarLoadResult:
addr_objects: int = 0
houses: int = 0
apartments: int = 0
upserted: int = 0
duration_sec: float = field(default=0.0)
def to_counts(self) -> dict[str, int]:
return {
"addr_objects": self.addr_objects,
"houses": self.houses,
"apartments": self.apartments,
"upserted": self.upserted,
"duration_sec": int(self.duration_sec),
}
def load_gar_region(
dir_path: str,
region_code: str,
gar_version: str,
*,
db: Session | None = None,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> GarLoadResult:
"""Парс ГАР-каталога региона → UPSERT в gar_house_flats (коммит). Без матча.
Возвращает счётчики (houses, apartments, upserted). Если db не передан — открывает
собственную SessionLocal. Матч (match_houses_to_gar) — отдельный шаг (см. CLI).
"""
start = time.monotonic()
own_session = db is None
if db is None:
from app.core.db import SessionLocal
db = SessionLocal()
try:
rows, stats = parse_gar_region(dir_path, region_code, gar_version)
upserted = upsert_gar_houses(db, rows, batch_size=batch_size)
db.commit()
result = GarLoadResult(
addr_objects=stats.addr_objects,
houses=stats.houses,
apartments=stats.apartments,
upserted=upserted,
duration_sec=time.monotonic() - start,
)
logger.info(
"load_gar_region: region=%s version=%s houses=%d apartments=%d upserted=%d "
"duration=%.1fs",
region_code,
gar_version,
result.houses,
result.apartments,
result.upserted,
result.duration_sec,
)
return result
except Exception:
logger.exception("load_gar_region: region=%s FAILED", region_code)
try:
db.rollback()
except Exception:
pass
raise
finally:
if own_session:
db.close()