feat(etl): auto_core_dev_v5 — core-name+dev backfill mapping'а (#2177 шаг 2) (#2237)
Some checks failed
Deploy / changes (push) Successful in 7s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Successful in 2m8s
Deploy / build-frontend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-02 21:59:16 +00:00
parent 8ea52b9b7a
commit e9c8d05e1d
4 changed files with 800 additions and 5 deletions

View file

@ -26,7 +26,9 @@ from app.services.etl.objective_backfill import (
AUTO_ACCEPT_THRESHOLD, AUTO_ACCEPT_THRESHOLD,
AUTO_ACCEPT_THRESHOLD_V2, AUTO_ACCEPT_THRESHOLD_V2,
REVIEW_THRESHOLD, REVIEW_THRESHOLD,
apply_core_matches,
auto_apply_matches, auto_apply_matches,
find_core_matches,
find_match_candidates, find_match_candidates,
trigger_mv_refresh, trigger_mv_refresh,
) )
@ -104,6 +106,37 @@ def run_objective_backfill(
return result return result
@router.post("/objective-core-backfill")
def run_objective_core_backfill(
db: Annotated[Session, Depends(get_db)],
dry_run: Annotated[bool, Query(description="Preview без insertions (default True)")] = True,
refresh_mv: Annotated[bool, Query(description="REFRESH mv_layout_velocity после apply")] = True,
) -> dict[str, object]:
"""Core-name + dev-confirm проход (#2177 шаг 2, match_method='auto_core_dev_v5').
Нормализует ЯДРО имени обеих сторон (normalize_complex_name, #2198) и мапит
несопоставленные domrf ЕКБ-объекты на objective-проекты. Пишутся ТОЛЬКО tier_a
(ровно 1 кандидат + подтверждение застройщика). tier_b/ambiguous в counts.
Дефолт dry_run=True (безопасно). Реальный insert ?dry_run=false.
Returns dict:
inserted / conflict_skipped / error_skipped / tier_a_total из apply;
tier_b / ambiguous / skipped_taken счётчики отчёта;
mv_rows_after_refresh строк в MV после REFRESH (0 если dry-run / нет вставок).
"""
report = find_core_matches(db)
result: dict[str, object] = dict(apply_core_matches(db, report, dry_run=dry_run))
result.update(report.counts())
mv_rows = 0
if refresh_mv and not dry_run and result.get("inserted", 0):
mv_rows = trigger_mv_refresh(db)
logger.info("mv_layout_velocity refreshed after core-backfill: %d rows", mv_rows)
result["mv_rows_after_refresh"] = mv_rows
return result
@router.post("/nspd-denorm-backfill") @router.post("/nspd-denorm-backfill")
def run_nspd_denorm_backfill( def run_nspd_denorm_backfill(
limit: Annotated[ limit: Annotated[

View file

@ -12,20 +12,31 @@ Schema facts (confirmed via pg MCP):
колонки match_method + match_score + is_reviewed поддерживают audit trail. колонки match_method + match_score + is_reviewed поддерживают audit trail.
match_method history: match_method history:
'fuzzy' legacy Anton SQLite import (avg score 0.98, 127 rows) 'fuzzy' legacy Anton SQLite import (avg score 0.98, 127 rows)
'fuzzy_trgm' pg_trgm backfill, auto-accept threshold=0.85 (первый запуск) 'fuzzy_trgm' pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
'fuzzy_v2' pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44) 'fuzzy_v2' pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
'manual' ручная корректура 'auto_core_dev_v5' core-name normalize (#2198 normalize_complex_name) обеих сторон +
dev-confirm. trgm по СЫРЫМ именам давал ~300 маппингов, 1253 obj_id
остались (низкая similarity: «Жилой комплекс "Малевич" (ЖК "Малевич")»
vs «Малевич»). Ядро-нормализация схлопывает обёртки exact-core match;
dev-подтверждение (пересечение токенов / подстрока) отсеивает
одноимённые чужие проекты (Объектив = только ЕКБ). #2177 шаг 2.
'manual' ручная корректура
""" """
from __future__ import annotations from __future__ import annotations
import argparse
import logging import logging
from dataclasses import dataclass import re
import sys
from dataclasses import dataclass, field
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.site_finder.best_layouts import normalize_complex_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Порог для auto-insert (высокая уверенность) # Порог для auto-insert (высокая уверенность)
@ -234,3 +245,427 @@ def trigger_mv_refresh(db: Session) -> int:
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
return refresh_layout_velocity(db, concurrently=True) return refresh_layout_velocity(db, concurrently=True)
# ════════════════════════════════════════════════════════════════════════════
# Core-name pass (#2177 шаг 2) — auto_core_dev_v5
# ════════════════════════════════════════════════════════════════════════════
# objective_group у всех существующих строк mapping = 'Екатеринбург'
# (Объектив покрывает только ЕКБ). Новые строки пишем с тем же значением.
OBJECTIVE_GROUP = "Екатеринбург"
CORE_MATCH_METHOD = "auto_core_dev_v5"
CORE_MATCH_SCORE = 0.90
CORE_MATCH_NOTE = "#2177 step2: exact-core + dev-confirm"
# Префиксы/обёртки орг-формы застройщика, срезаемые перед сравнением.
# Порядок применения не важен (все режутся из целого норм-имени).
_DEV_JUNK_RE = re.compile(
r"""
\b(?:
ооо | оао | пао | зао | ао | ип
| сз # СЗ = специализированный застройщик (аббрев.)
| специализированн\w* # «специализированный застройщик»
| застройщик\w*
| девелопмент\w*
| групп\w* # групп / группа
| компани\w*
| инвест\w* # инвест / инвестиции (частый шум)
| строительн\w*
)\b
""",
re.VERBOSE,
)
def _norm_dev(raw: str | None) -> str:
"""Нормализовать имя застройщика до сравниваемого ядра.
lower срез кавычек/скобок срез орг-формы (ООО/АО/ПАО/ЗАО/«СЗ»/
«специализированный застройщик»/«девелопмент»/«групп(а)») схлопывание
пробелов. Пустой / None ''.
Namespaced на _NAME_-уровне: возвращаем очищенную строку, дальнейшая
классификация (пересечение токенов / подстрока) в find_core_matches.
"""
if not raw:
return ""
name = raw.strip().lower()
# убрать кавычки-ёлочки/лапки/скобки — оставить только содержательные символы
name = re.sub(r"[«»\"'`()\[\]]", " ", name)
name = _DEV_JUNK_RE.sub(" ", name)
# мусорные разделители → пробел (точки в «Астон.» и т.п. не трогаем на dev-стороне:
# dev-имена обычно без них, но на всякий нормализуем распространённые)
name = re.sub(r"[.,;/\\]", " ", name)
name = re.sub(r"\s+", " ", name).strip()
return name
def _dev_confirms(domrf_dev: str, objective_devs: set[str]) -> bool:
"""True если domrf-застройщик подтверждает связь с objective-проектом.
У одного objective project_name бывает НЕСКОЛЬКО developers (SPV / очереди)
подтверждение по ЛЮБОМУ из них. Критерий (любой):
пересечение токенов норм-имён (общий содержательный токен), ИЛИ
одно норм-имя подстрока другого (в любую сторону), НО только когда
короткая сторона 4 символов: «дом»/«юг»/«ак» как подстрока чужого
длинного имени («Домострой», «Югстройинвест») ложное подтверждение,
а именно dev-confirm последняя защита от generic-core коллизий.
Пустые имена не подтверждают (нет сигнала).
"""
d = _norm_dev(domrf_dev)
if not d:
return False
d_tokens = {t for t in d.split() if len(t) >= 3}
for raw_odev in objective_devs:
o = _norm_dev(raw_odev)
if not o:
continue
if min(len(d), len(o)) >= 4 and (d in o or o in d):
return True
o_tokens = {t for t in o.split() if len(t) >= 3}
if d_tokens & o_tokens:
return True
return False
@dataclass
class CoreMatch:
"""Один core-match domrf-объекта к objective-проекту."""
domrf_obj_id: int
domrf_comm_name: str
domrf_dev_name: str | None
core: str
objective_project_name: str
objective_project_id: int | None
objective_developers: list[str]
dev_confirmed: bool
@dataclass
class CoreMatchReport:
"""Итог классификации core-pass.
tier_a ровно 1 objective-кандидат И dev-подтверждён пишем в БД.
tier_b ровно 1 кандидат, dev НЕ подтверждён кандидат на DaData-гео
проход, в БД НЕ пишем (только отчёт).
ambiguous >1 objective-кандидатов по core в отчёт, разрешение вручную/гео.
skipped_taken objective_complex_name уже занят в mapping (UNIQUE-констрейнт;
его domrf-группа уже покрыта дубли не нужны).
"""
tier_a: list[CoreMatch] = field(default_factory=list)
tier_b: list[CoreMatch] = field(default_factory=list)
ambiguous: list[CoreMatch] = field(default_factory=list)
skipped_taken: list[CoreMatch] = field(default_factory=list)
def counts(self) -> dict[str, int]:
return {
"tier_a": len(self.tier_a),
"tier_b": len(self.tier_b),
"ambiguous": len(self.ambiguous),
"skipped_taken": len(self.skipped_taken),
}
# ── SQL: несопоставленные domrf ЕКБ-объекты, latest snapshot per obj_id ───────
# ЛОВУШКА is_ekb (прод-проверка 2026-07-03): флаг проставляется per-snapshot и на
# свежих снапшотах ПУСТ (все latest-строки is_ekb=false — см. также комментарий
# в 155_objective_mapping_trgm_backfill.sql). `WHERE is_ekb = true` внутри
# DISTINCT ON отдал бы устаревшие имена со старых снапшотов (или 0 строк после
# DISTINCT). Поэтому: ЕКБ-принадлежность = «obj_id имел is_ekb=true хоть на одном
# снапшоте» (1285 живых объектов), а строку берём с LATEST снапшота без флага.
_DOMRF_UNMAPPED_SQL = text("""
SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.comm_name, o.dev_name
FROM domrf_kn_objects o
WHERE o.comm_name IS NOT NULL
AND o.obj_id IN (
SELECT DISTINCT e.obj_id FROM domrf_kn_objects e WHERE e.is_ekb = true
)
AND NOT EXISTS (
SELECT 1 FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = o.obj_id
)
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
""")
# ── SQL: objective-проекты — distinct project_name + агрегаты developer/id ────
# objective_project_id: в objective_lots есть колонка objective_project_id
# (68_schema_objective.sql). Берём MAX (стабилен per project_name; NULL если нет).
_OBJECTIVE_PROJECTS_SQL = text("""
SELECT
ol.project_name,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers,
MAX(ol.objective_project_id) AS objective_project_id
FROM objective_lots ol
WHERE ol.project_name IS NOT NULL
AND ol.project_name <> ''
GROUP BY ol.project_name
""")
# ── SQL: уже занятые objective_complex_name в текущей группе ──────────────────
_TAKEN_NAMES_SQL = text("""
SELECT objective_complex_name
FROM objective_complex_mapping
WHERE objective_group = CAST(:group AS text)
""")
def find_core_matches(db: Session) -> CoreMatchReport:
"""Сопоставить несопоставленные domrf ЕКБ-объекты с objective-проектами по ядру.
Обе стороны нормализуются `normalize_complex_name` (#2198): обёртки вроде
«Жилой комплекс "Малевич" (ЖК "Малевич")» схлопываются до ядра «малевич»,
что даёт exact-core match там, где trgm по сырым именам не дотягивал.
Классификация (см. CoreMatchReport):
tier_a 1 objective-кандидат + dev-подтверждён (пишем);
tier_b 1 кандидат, dev НЕ подтверждён (DaData-гео проход, не пишем);
ambiguous >1 кандидата (ручное/гео разрешение);
skipped objective_complex_name уже в mapping (UNIQUE-констрейнт).
Реального обращения к БД в тестах нет db.execute мокается по паттерну файла.
"""
report = CoreMatchReport()
# objective-сторона: core → [(project_name, {devs}, project_id), ...]
objective_by_core: dict[str, list[tuple[str, set[str], int | None]]] = {}
for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
project_name = str(row[0])
developers = {str(d) for d in (row[1] or []) if d}
project_id = int(row[2]) if row[2] is not None else None
core = normalize_complex_name(project_name)
if not core:
continue
objective_by_core.setdefault(core, []).append((project_name, developers, project_id))
# уже занятые objective_complex_name (skip — их группа уже покрыта)
taken_names: set[str] = {
str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all()
}
# domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id
for row in db.execute(_DOMRF_UNMAPPED_SQL).all():
obj_id = int(row[0])
comm_name = str(row[1])
dev_name = str(row[2]) if row[2] is not None else None
core = normalize_complex_name(comm_name)
if not core:
continue
candidates = objective_by_core.get(core)
if not candidates:
continue # нет objective-проекта с таким ядром — не наш случай
if len(candidates) > 1:
# >1 objective project_name делят одно ядро → ambiguous (не выбираем вслепую)
proj_name, devs, proj_id = candidates[0]
report.ambiguous.append(
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
)
continue
proj_name, devs, proj_id = candidates[0]
if proj_name in taken_names:
report.skipped_taken.append(
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
)
continue
match = _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
if match.dev_confirmed:
report.tier_a.append(match)
else:
report.tier_b.append(match)
logger.info("find_core_matches: %s", report.counts())
return report
def _build_core_match(
obj_id: int,
comm_name: str,
dev_name: str | None,
core: str,
proj_name: str,
proj_id: int | None,
devs: set[str],
) -> CoreMatch:
"""Сконструировать CoreMatch + посчитать dev_confirmed."""
return CoreMatch(
domrf_obj_id=obj_id,
domrf_comm_name=comm_name,
domrf_dev_name=dev_name,
core=core,
objective_project_name=proj_name,
objective_project_id=proj_id,
objective_developers=sorted(devs),
dev_confirmed=_dev_confirms(dev_name or "", devs),
)
def apply_core_matches(
db: Session,
report: CoreMatchReport,
*,
dry_run: bool = True,
) -> dict[str, int]:
"""Вставить ТОЛЬКО tier_a в objective_complex_mapping (auto_core_dev_v5).
tier_b / ambiguous / skipped НЕ пишутся (см. CoreMatchReport). Каждая вставка
в per-row SAVEPOINT (backend.md) с ON CONFLICT (objective_complex_name,
objective_group) DO NOTHING гонка/дубль не роняет весь проход.
Args:
db: SQLAlchemy sync Session.
report: результат find_core_matches().
dry_run: True (default) только логирует counts + первые 30 пар, БЕЗ insert.
Returns:
dict: inserted, conflict_skipped, error_skipped, tier_a_total.
"""
counts = report.counts()
logger.info(
"apply_core_matches (dry_run=%s): tier_a=%d tier_b=%d ambiguous=%d skipped_taken=%d",
dry_run,
counts["tier_a"],
counts["tier_b"],
counts["ambiguous"],
counts["skipped_taken"],
)
for m in report.tier_a[:30]:
logger.info(
" tier_a: obj_id=%s core=%r domrf=%r → objective=%r (dev %r ~ %r)",
m.domrf_obj_id,
m.core,
m.domrf_comm_name,
m.objective_project_name,
m.domrf_dev_name,
m.objective_developers,
)
if dry_run:
return {
"inserted": 0,
"conflict_skipped": 0,
"error_skipped": 0,
"tier_a_total": counts["tier_a"],
}
inserted = 0
conflict_skipped = 0
error_skipped = 0
for m in report.tier_a:
try:
with db.begin_nested():
result = db.execute(
text(
"""
INSERT INTO objective_complex_mapping
(objective_complex_name, objective_project_id, domrf_obj_id,
objective_group, match_method, match_score, is_reviewed, note)
VALUES (
CAST(:name AS text),
CAST(:project_id AS bigint),
CAST(:obj_id AS bigint),
CAST(:group AS text),
CAST(:method AS text),
CAST(:score AS numeric),
CAST(:reviewed AS boolean),
CAST(:note AS text)
)
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
"""
),
{
"name": m.objective_project_name,
"project_id": m.objective_project_id,
"obj_id": m.domrf_obj_id,
"group": OBJECTIVE_GROUP,
"method": CORE_MATCH_METHOD,
"score": CORE_MATCH_SCORE,
"reviewed": False,
"note": CORE_MATCH_NOTE,
},
)
if result.rowcount > 0:
inserted += 1
else:
conflict_skipped += 1
except Exception as e:
logger.warning(
"core insert failed для %s%s: %s",
m.domrf_comm_name,
m.objective_project_name,
e,
)
error_skipped += 1
db.commit()
logger.info(
"apply_core_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
inserted,
conflict_skipped,
error_skipped,
)
return {
"inserted": inserted,
"conflict_skipped": conflict_skipped,
"error_skipped": error_skipped,
"tier_a_total": counts["tier_a"],
}
def run_core_pass(*, apply: bool = False) -> dict[str, int]:
"""CLI-обёртка core-pass: открыть Session, найти матчи, (опц.) применить.
Дефолт dry-run. Реальный insert только при apply=True.
"""
from app.core.db import SessionLocal
db = SessionLocal()
try:
report = find_core_matches(db)
return apply_core_matches(db, report, dry_run=not apply)
finally:
db.close()
def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="objective_backfill core-pass (#2177 step2, auto_core_dev_v5)."
)
parser.add_argument(
"--core-pass",
action="store_true",
help="Запустить core-name + dev-confirm проход (иначе no-op).",
)
parser.add_argument(
"--apply",
action="store_true",
help="Реально писать tier_a в БД (по умолчанию — dry-run).",
)
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if not args.core_pass:
parser.print_help()
return 0
result = run_core_pass(apply=args.apply)
logger.info("core-pass result: %s", result)
return 0
if __name__ == "__main__":
sys.exit(_main())

View file

@ -10,9 +10,16 @@ from unittest.mock import MagicMock, patch
from app.services.etl.objective_backfill import ( from app.services.etl.objective_backfill import (
AUTO_ACCEPT_THRESHOLD, AUTO_ACCEPT_THRESHOLD,
CORE_MATCH_METHOD,
CORE_MATCH_SCORE,
REVIEW_THRESHOLD, REVIEW_THRESHOLD,
CoreMatchReport,
MatchCandidate, MatchCandidate,
_dev_confirms,
_norm_dev,
apply_core_matches,
auto_apply_matches, auto_apply_matches,
find_core_matches,
find_match_candidates, find_match_candidates,
trigger_mv_refresh, trigger_mv_refresh,
) )
@ -180,3 +187,256 @@ def test_auto_apply_matches_conflict_counted_as_skipped() -> None:
assert result["auto_accepted"] == 0 assert result["auto_accepted"] == 0
assert result["skipped"] == 1 assert result["skipped"] == 1
assert result["review_queue"] == 0 assert result["review_queue"] == 0
# ═════════════════════════════════════════════════════════════════════════════
# Core-pass (#2177 шаг 2) — auto_core_dev_v5
# ═════════════════════════════════════════════════════════════════════════════
def _make_core_db(
domrf_rows: list[tuple[int, str, str | None]],
objective_rows: list[tuple[str, list[str] | None, int | None]],
taken_names: list[str],
) -> MagicMock:
"""Mock Session где 3 подряд db.execute().all() отдают
objective-проекты, taken-имена, domrf-объекты (в порядке find_core_matches).
find_core_matches вызывает execute в фиксированном порядке:
1) _OBJECTIVE_PROJECTS_SQL, 2) _TAKEN_NAMES_SQL, 3) _DOMRF_UNMAPPED_SQL.
"""
obj_result = MagicMock()
obj_result.all.return_value = objective_rows
taken_result = MagicMock()
taken_result.all.return_value = [(n,) for n in taken_names]
domrf_result = MagicMock()
domrf_result.all.return_value = domrf_rows
mock_db = MagicMock()
mock_db.execute.side_effect = [obj_result, taken_result, domrf_result]
return mock_db
# ── _norm_dev / _dev_confirms ────────────────────────────────────────────────
def test_norm_dev_strips_org_form() -> None:
"""_norm_dev срезает орг-форму, кавычки, «СЗ», схлопывает пробелы."""
assert _norm_dev('ООО "СЗ "Малевич"') == "малевич"
assert _norm_dev("PRINZIP") == "prinzip"
assert _norm_dev("ООО Брозэкс") == "брозэкс"
assert _norm_dev("ООО Специализированный застройщик Арсенал") == "арсенал"
assert _norm_dev(None) == ""
assert _norm_dev(" ") == ""
def test_dev_confirms_matches_any_spv() -> None:
"""dev-подтверждение по ЛЮБОМУ из нескольких objective developers (SPV)."""
# SPV «СЗ Малевич» подтверждает, хотя PRINZIP — нет.
assert _dev_confirms('ООО "СЗ "Малевич"', {"PRINZIP", 'СЗ "Малевич"'}) is True
assert _dev_confirms('ООО "СЗ "Малевич"', {"PRINZIP"}) is False
# разные застройщики — не подтверждает
assert _dev_confirms("ООО СЗ Арсенал", {"Брозэкс"}) is False
# пустой domrf-dev — нет сигнала
assert _dev_confirms("", {"Брозэкс"}) is False
def test_dev_confirms_short_substring_guard() -> None:
"""Короткий (<4 симв.) корень не подтверждает подстрокой чужое длинное имя.
Ревью 2026-07-03: «ООО Дом»«дом» как подстрока «Домострой» ложное
подтверждение; dev-confirm последняя защита от generic-core коллизий.
"""
assert _dev_confirms("ООО Дом", {"Домострой"}) is False
assert _dev_confirms("ООО СЗ Юг", {"Югстройинвест"}) is False
# ≥4 символов подстрокой — по-прежнему подтверждает
assert _dev_confirms("Брусника", {"Брусника-Урал"}) is True
# ── find_core_matches: классификация tier_a / tier_b / ambiguous / skip ──────
def test_find_core_matches_tier_a_dev_confirmed() -> None:
"""Малевич: 1 кандидат + SPV-подтверждение → tier_a."""
mock_db = _make_core_db(
domrf_rows=[(101, 'Жилой комплекс "Малевич" (ЖК "Малевич")', 'ООО "СЗ "Малевич"')],
objective_rows=[("Малевич", ["PRINZIP", 'СЗ "Малевич"'], 7001)],
taken_names=[],
)
report = find_core_matches(mock_db)
assert len(report.tier_a) == 1
assert report.tier_a[0].domrf_obj_id == 101
assert report.tier_a[0].objective_project_name == "Малевич"
assert report.tier_a[0].objective_project_id == 7001
assert report.tier_a[0].dev_confirmed is True
assert report.tier_b == []
assert report.ambiguous == []
def test_find_core_matches_tier_b_dev_not_confirmed() -> None:
"""Арсенал↔Брозэкс: 1 кандидат, dev НЕ совпал → tier_b (не в БД)."""
mock_db = _make_core_db(
domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
taken_names=[],
)
report = find_core_matches(mock_db)
assert report.tier_a == []
assert len(report.tier_b) == 1
assert report.tier_b[0].domrf_obj_id == 202
assert report.tier_b[0].dev_confirmed is False
def test_find_core_matches_ambiguous_two_candidates() -> None:
"""Меридиан: 2 objective project_name делят одно ядро → ambiguous."""
mock_db = _make_core_db(
domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")],
objective_rows=[
("Меридиан", ["Девелопер"], 7003),
("Меридиан ", ["Другой"], 7004), # то же ядро «меридиан»
],
taken_names=[],
)
report = find_core_matches(mock_db)
assert report.tier_a == []
assert report.tier_b == []
assert len(report.ambiguous) == 1
assert report.ambiguous[0].domrf_obj_id == 303
def test_find_core_matches_skips_taken_name() -> None:
"""objective_complex_name уже в mapping → skipped_taken, не tier_a."""
mock_db = _make_core_db(
domrf_rows=[(404, 'ЖК "Симфония"', "ООО Симфония")],
objective_rows=[("Симфония", ["Симфония"], 7005)],
taken_names=["Симфония"], # уже занято
)
report = find_core_matches(mock_db)
assert report.tier_a == []
assert len(report.skipped_taken) == 1
assert report.skipped_taken[0].objective_project_name == "Симфония"
def test_find_core_matches_no_objective_candidate_dropped() -> None:
"""domrf-ядро без objective-проекта — молча пропускается (не наш случай)."""
mock_db = _make_core_db(
domrf_rows=[(505, 'ЖК "Несуществующий"', "ООО X")],
objective_rows=[("Малевич", ["PRINZIP"], 7001)],
taken_names=[],
)
report = find_core_matches(mock_db)
assert report.counts() == {
"tier_a": 0,
"tier_b": 0,
"ambiguous": 0,
"skipped_taken": 0,
}
# ── apply_core_matches: dry-run / apply / ON CONFLICT ────────────────────────
def _tier_a_report() -> CoreMatchReport:
"""Отчёт с ровно одним tier_a (Малевич + SPV-подтверждение)."""
mock_db = _make_core_db(
domrf_rows=[(101, 'Жилой комплекс "Малевич"', 'ООО "СЗ "Малевич"')],
objective_rows=[("Малевич", ['СЗ "Малевич"'], 7001)],
taken_names=[],
)
return find_core_matches(mock_db)
def test_apply_core_matches_dry_run_no_write() -> None:
"""dry_run=True → только counts, execute/commit не вызываются."""
report = _tier_a_report()
assert len(report.tier_a) == 1
mock_db = MagicMock()
result = apply_core_matches(mock_db, report, dry_run=True)
assert result["inserted"] == 0
assert result["tier_a_total"] == 1
mock_db.execute.assert_not_called()
mock_db.commit.assert_not_called()
def test_apply_core_matches_writes_tier_a_with_method_score() -> None:
"""apply пишет tier_a с match_method='auto_core_dev_v5' и score=0.90."""
report = _tier_a_report()
savepoint_cm = MagicMock()
savepoint_cm.__enter__ = MagicMock(return_value=None)
savepoint_cm.__exit__ = MagicMock(return_value=False)
exec_result = MagicMock()
exec_result.rowcount = 1
mock_db = MagicMock()
mock_db.begin_nested.return_value = savepoint_cm
mock_db.execute.return_value = exec_result
result = apply_core_matches(mock_db, report, dry_run=False)
assert result["inserted"] == 1
assert result["conflict_skipped"] == 0
mock_db.commit.assert_called_once()
# проверяем переданные в INSERT параметры
params = mock_db.execute.call_args[0][1]
assert params["method"] == CORE_MATCH_METHOD
assert params["score"] == CORE_MATCH_SCORE
assert params["reviewed"] is False
assert params["group"] == "Екатеринбург"
assert params["name"] == "Малевич"
assert params["obj_id"] == 101
assert params["project_id"] == 7001
def test_apply_core_matches_on_conflict_counts_skip() -> None:
"""ON CONFLICT DO NOTHING (rowcount=0) → conflict_skipped, не падает."""
report = _tier_a_report()
savepoint_cm = MagicMock()
savepoint_cm.__enter__ = MagicMock(return_value=None)
savepoint_cm.__exit__ = MagicMock(return_value=False)
exec_result = MagicMock()
exec_result.rowcount = 0 # ON CONFLICT
mock_db = MagicMock()
mock_db.begin_nested.return_value = savepoint_cm
mock_db.execute.return_value = exec_result
result = apply_core_matches(mock_db, report, dry_run=False)
assert result["inserted"] == 0
assert result["conflict_skipped"] == 1
mock_db.commit.assert_called_once()
def test_apply_core_matches_ignores_non_tier_a() -> None:
"""tier_b / ambiguous / skipped НЕ пишутся — только tier_a."""
report = CoreMatchReport()
# только tier_b и ambiguous, tier_a пуст
tb_db = _make_core_db(
domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
taken_names=[],
)
report = find_core_matches(tb_db)
assert len(report.tier_b) == 1
assert report.tier_a == []
mock_db = MagicMock()
result = apply_core_matches(mock_db, report, dry_run=False)
assert result["inserted"] == 0
assert result["tier_a_total"] == 0
mock_db.execute.assert_not_called()
# commit всё равно вызывается (пустой tier_a — no-op транзакция)
mock_db.commit.assert_called_once()

View file

@ -2386,6 +2386,37 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/admin/etl/objective-core-backfill": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Run Objective Core Backfill
* @description Core-name + dev-confirm проход (#2177 шаг 2, match_method='auto_core_dev_v5').
*
* Нормализует ЯДРО имени обеих сторон (normalize_complex_name, #2198) и мапит
* несопоставленные domrf ЕКБ-объекты на objective-проекты. Пишутся ТОЛЬКО tier_a
* (ровно 1 кандидат + подтверждение застройщика). tier_b/ambiguous в counts.
*
* Дефолт dry_run=True (безопасно). Реальный insert ?dry_run=false.
*
* Returns dict:
* inserted / conflict_skipped / error_skipped / tier_a_total из apply;
* tier_b / ambiguous / skipped_taken счётчики отчёта;
* mv_rows_after_refresh строк в MV после REFRESH (0 если dry-run / нет вставок).
*/
post: operations["run_objective_core_backfill_api_v1_admin_etl_objective_core_backfill_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/etl/nspd-denorm-backfill": { "/api/v1/admin/etl/nspd-denorm-backfill": {
parameters: { parameters: {
query?: never; query?: never;
@ -9007,6 +9038,42 @@ export interface operations {
}; };
}; };
}; };
run_objective_core_backfill_api_v1_admin_etl_objective_core_backfill_post: {
parameters: {
query?: {
/** @description Preview без insertions (default True) */
dry_run?: boolean;
/** @description REFRESH mv_layout_velocity после apply */
refresh_mv?: boolean;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
run_nspd_denorm_backfill_api_v1_admin_etl_nspd_denorm_backfill_post: { run_nspd_denorm_backfill_api_v1_admin_etl_nspd_denorm_backfill_post: {
parameters: { parameters: {
query?: { query?: {