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
673 lines
30 KiB
Python
673 lines
30 KiB
Python
"""ГАР/ФИАС 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()
|