diff --git a/backend/app/api/v1/admin_etl.py b/backend/app/api/v1/admin_etl.py index 97d38942..185c1e51 100644 --- a/backend/app/api/v1/admin_etl.py +++ b/backend/app/api/v1/admin_etl.py @@ -26,7 +26,9 @@ from app.services.etl.objective_backfill import ( AUTO_ACCEPT_THRESHOLD, AUTO_ACCEPT_THRESHOLD_V2, REVIEW_THRESHOLD, + apply_core_matches, auto_apply_matches, + find_core_matches, find_match_candidates, trigger_mv_refresh, ) @@ -104,6 +106,37 @@ def run_objective_backfill( 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") def run_nspd_denorm_backfill( limit: Annotated[ diff --git a/backend/app/services/etl/objective_backfill.py b/backend/app/services/etl/objective_backfill.py index 120ce993..10e06dd9 100644 --- a/backend/app/services/etl/objective_backfill.py +++ b/backend/app/services/etl/objective_backfill.py @@ -12,20 +12,31 @@ Schema facts (confirmed via pg MCP): колонки match_method + match_score + is_reviewed поддерживают audit trail. match_method history: - 'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows) - 'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск) - 'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44) - 'manual' — ручная корректура + 'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows) + 'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск) + 'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44) + '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 +import argparse import logging -from dataclasses import dataclass +import re +import sys +from dataclasses import dataclass, field from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.site_finder.best_layouts import normalize_complex_name + logger = logging.getLogger(__name__) # Порог для 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 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()) diff --git a/backend/tests/services/test_objective_backfill.py b/backend/tests/services/test_objective_backfill.py index 99197115..6a77cf21 100644 --- a/backend/tests/services/test_objective_backfill.py +++ b/backend/tests/services/test_objective_backfill.py @@ -10,9 +10,16 @@ from unittest.mock import MagicMock, patch from app.services.etl.objective_backfill import ( AUTO_ACCEPT_THRESHOLD, + CORE_MATCH_METHOD, + CORE_MATCH_SCORE, REVIEW_THRESHOLD, + CoreMatchReport, MatchCandidate, + _dev_confirms, + _norm_dev, + apply_core_matches, auto_apply_matches, + find_core_matches, find_match_candidates, trigger_mv_refresh, ) @@ -180,3 +187,256 @@ def test_auto_apply_matches_conflict_counted_as_skipped() -> None: assert result["auto_accepted"] == 0 assert result["skipped"] == 1 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() diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 37ff959f..41fcfe85 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -2386,6 +2386,37 @@ export interface paths { patch?: 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": { parameters: { 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: { parameters: { query?: {