feat(etl): auto_core_dev_v5 — core-name+dev backfill mapping'а (#2177 шаг 2) #2237
4 changed files with 800 additions and 5 deletions
|
|
@ -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[
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue