feat(etl): auto_core_dev_v5 — core-name + dev-confirm backfill проход (#2177 шаг 2)
Some checks failed
CI / backend-tests (pull_request) Successful in 14m13s
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 6s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m44s
CI / frontend-tests (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-07-03 02:40:45 +05:00
parent 5fb5675871
commit f8d5a14290
3 changed files with 733 additions and 5 deletions

View file

@ -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[

View file

@ -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())

View file

@ -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()