"""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 »; иначе 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 » в 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