220 lines
8.9 KiB
Python
220 lines
8.9 KiB
Python
"""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
|