feat(etl): auto_core_dev_v5 — core-name+dev backfill mapping'а (#2177 шаг 2) (#2237)
Some checks failed
Some checks failed
This commit is contained in:
parent
8ea52b9b7a
commit
e9c8d05e1d
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,
|
||||||
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[
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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?: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue