gendesign/backend/app/services/etl/mapping_review.py
bot-backend dfc1ee8be0
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m47s
Deploy / build-worker (push) Successful in 5m42s
Deploy / build-frontend (push) Successful in 5m55s
Deploy / deploy (push) Has been cancelled
feat: ревью-инструмент авто-маппингов Объектив↔ДОМ.РФ (/admin/mapping-review) (#2278)
2026-07-03 07:59:13 +00:00

220 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Review-инструмент авто-маппингов objective_complex_mapping (feat/mapping-review-page).
Авто-проходы (auto_core_dev_v5 / auto_core_geo_v6 / fuzzy_* / …) пишут строки
с is_reviewed=false. Здесь — чтение этих строк С ОБОГАЩЕНИЕМ для глаз ревьюера,
подтверждение (approve → is_reviewed=true) и отклонение (reject → DELETE строки,
чтобы отклонённый маппинг не травил mv_layout_velocity §4.2).
Обогащение:
• domrf comm_name / dev_name — LATEST snapshot по domrf_obj_id
(snapshot_date DESC — как в objective_backfill._DOMRF_UNMAPPED_SQL).
• objective developers — агрегат objective_lots.developer, SCOPED по
(project_name = objective_complex_name). Урок 3.8с: агрегат по project_name,
НЕ глобальный — иначе список застройщиков одного проекта протекает в чужой.
Чтения — parametrized `text(...)` + CAST(:x AS type) (backend.md psycopg3).
DELETE (reject) — только по PK с RETURNING для полного лога удаляемой строки.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ── SQL: строки mapping + обогащение domrf latest snapshot + objective devs ────
# LEFT JOIN LATERAL к domrf_kn_objects: DISTINCT-ON-эквивалент через ORDER BY
# snapshot_date DESC LIMIT 1 (latest snapshot по obj_id). objective devs —
# коррелированный агрегат по project_name (scoped, урок 3.8с).
# Сорт: is_reviewed asc (unreviewed сверху), match_score asc NULLS FIRST
# (самые сомнительные / без скора сверху), id для стабильности.
_LIST_SQL = text("""
SELECT
m.id,
m.objective_complex_name,
m.objective_project_id,
m.objective_group,
m.domrf_obj_id,
m.match_method,
m.match_score,
m.is_reviewed,
m.note,
m.created_at,
d.comm_name AS domrf_comm_name,
d.dev_name AS domrf_dev_name,
odev.developers AS objective_developers
FROM objective_complex_mapping m
LEFT JOIN LATERAL (
SELECT o.comm_name, o.dev_name
FROM domrf_kn_objects o
WHERE o.obj_id = m.domrf_obj_id
ORDER BY o.snapshot_date DESC NULLS LAST
LIMIT 1
) d ON m.domrf_obj_id IS NOT NULL
LEFT JOIN LATERAL (
SELECT ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers
FROM objective_lots ol
WHERE ol.project_name = m.objective_complex_name
AND ol.developer IS NOT NULL
) odev ON TRUE
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
ORDER BY m.is_reviewed ASC, m.match_score ASC NULLS FIRST, m.id ASC
LIMIT CAST(:limit AS int) OFFSET CAST(:offset AS int)
""")
_COUNT_SQL = text("""
SELECT COUNT(*) AS total
FROM objective_complex_mapping m
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
""")
# ── SQL: approve — is_reviewed=true + append note. RETURNING для 404-детекта ───
# NULLIF/concat: пустой/NULL note → просто «approved <date>»; иначе append через
# перевод строки. Идемпотентно относительно is_reviewed (повторный approve OK).
_APPROVE_SQL = text("""
UPDATE objective_complex_mapping
SET is_reviewed = TRUE,
note = CASE
WHEN note IS NULL OR note = '' THEN CAST(:approve_note AS text)
ELSE note || E'\\n' || CAST(:approve_note AS text)
END,
updated_at = NOW()
WHERE id = CAST(:id AS bigint)
RETURNING id, objective_complex_name, domrf_obj_id,
match_method, match_score, is_reviewed, note
""")
# ── SQL: reject — DELETE по PK, RETURNING всей строки для лога (травит §4.2) ───
_DELETE_SQL = text("""
DELETE FROM objective_complex_mapping
WHERE id = CAST(:id AS bigint)
RETURNING id, objective_complex_name, objective_project_id, objective_group,
domrf_obj_id, match_method, match_score, is_reviewed, note,
created_at
""")
def _serialize_row(row: Any) -> dict[str, Any]:
"""Сериализовать строку list-запроса в loose dict для API (паттерн admin_etl)."""
score = row.match_score
created = row.created_at
return {
"id": row.id,
"objective_complex_name": row.objective_complex_name,
"objective_project_id": row.objective_project_id,
"objective_group": row.objective_group,
"domrf_obj_id": row.domrf_obj_id,
"match_method": row.match_method,
"match_score": float(score) if score is not None else None,
"is_reviewed": bool(row.is_reviewed),
"note": row.note,
"created_at": created.isoformat() if created else None,
# Обогащение для глаз ревьюера:
"domrf_comm_name": row.domrf_comm_name,
"domrf_dev_name": row.domrf_dev_name,
"objective_developers": list(row.objective_developers or []),
}
def list_mapping_review(
db: Session,
*,
only_unreviewed: bool = True,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""Список строк objective_complex_mapping + обогащение + total count.
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху), id.
Args:
db: SQLAlchemy sync Session.
only_unreviewed: True (default) — только is_reviewed=false.
limit: страница (строк).
offset: смещение страницы.
Returns:
dict: rows (list обогащённых строк), total (int, до limit/offset),
limit, offset, only_unreviewed.
"""
rows = db.execute(
_LIST_SQL,
{"only_unreviewed": only_unreviewed, "limit": limit, "offset": offset},
).all()
total = db.execute(_COUNT_SQL, {"only_unreviewed": only_unreviewed}).scalar_one()
return {
"rows": [_serialize_row(r) for r in rows],
"total": int(total),
"limit": limit,
"offset": offset,
"only_unreviewed": only_unreviewed,
}
def approve_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
Returns:
dict обновлённой строки, либо None если id не найден (→ 404 в роутере).
"""
approve_note = f"approved {date.today().isoformat()}"
row = db.execute(_APPROVE_SQL, {"id": mapping_id, "approve_note": approve_note}).first()
if row is None:
db.rollback()
return None
db.commit()
logger.info(
"mapping-review approve id=%s name=%r method=%s → is_reviewed=true",
row.id,
row.objective_complex_name,
row.match_method,
)
score = row.match_score
return {
"id": row.id,
"objective_complex_name": row.objective_complex_name,
"domrf_obj_id": row.domrf_obj_id,
"match_method": row.match_method,
"match_score": float(score) if score is not None else None,
"is_reviewed": bool(row.is_reviewed),
"note": row.note,
}
def reject_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
"""Отклонить маппинг: DELETE строки (травит mv_layout_velocity §4.2 иначе).
Перед удалением полная строка логируется через logger.info (в note писать
некуда — строки не будет). DELETE только по PK с RETURNING.
Returns:
dict удалённой строки (для эха в ответе), либо None если id не найден.
"""
row = db.execute(_DELETE_SQL, {"id": mapping_id}).first()
if row is None:
db.rollback()
return None
created = row.created_at
score = row.match_score
deleted: dict[str, Any] = {
"id": row.id,
"objective_complex_name": row.objective_complex_name,
"objective_project_id": row.objective_project_id,
"objective_group": row.objective_group,
"domrf_obj_id": row.domrf_obj_id,
"match_method": row.match_method,
"match_score": float(score) if score is not None else None,
"is_reviewed": bool(row.is_reviewed),
"note": row.note,
"created_at": created.isoformat() if created else None,
}
logger.info("mapping-review reject DELETE id=%s row=%s", row.id, deleted)
db.commit()
return deleted