From 959fc8e7bf5c53247553cb150c0d989243507bdc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 12:38:09 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20mapping-review=20tool=20=E2=80=94=20lis?= =?UTF-8?q?t/approve/reject=20auto-mappings=20+=20admin=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: mapping_review.py (list с LATERAL-обогащением domrf+developers, approve = is_reviewed+note, reject = DELETE по PK с RETURNING-логом) - 3 endpoint'а в admin_etl.py (GET list / POST approve / POST reject) - frontend: /admin/mapping-review — таблица с warn-подсветкой несовпадающих ядер, confirm-модалка на reject, пагинация; таб в admin layout - 10 тестов (happy/404/null-score) --- backend/app/api/v1/admin_etl.py | 81 ++- backend/app/services/etl/mapping_review.py | 220 +++++++ backend/tests/services/test_mapping_review.py | 272 +++++++++ frontend/src/app/admin/layout.tsx | 1 + .../src/app/admin/mapping-review/page.tsx | 571 ++++++++++++++++++ 5 files changed, 1143 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/etl/mapping_review.py create mode 100644 backend/tests/services/test_mapping_review.py create mode 100644 frontend/src/app/admin/mapping-review/page.tsx diff --git a/backend/app/api/v1/admin_etl.py b/backend/app/api/v1/admin_etl.py index 185c1e51..dd53cf6a 100644 --- a/backend/app/api/v1/admin_etl.py +++ b/backend/app/api/v1/admin_etl.py @@ -9,6 +9,13 @@ POST /api/v1/admin/etl/objective-backfill POST /api/v1/admin/etl/nspd-denorm-backfill Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps. +GET /api/v1/admin/etl/mapping-review + Список авто-маппингов objective_complex_mapping + обогащение для ревью. +POST /api/v1/admin/etl/mapping-review/{id}/approve + Подтвердить маппинг (is_reviewed=true + note append). +POST /api/v1/admin/etl/mapping-review/{id}/reject + Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2). + Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token header removed 2026-05-23 — двойная auth избыточна для pilot. """ @@ -16,12 +23,17 @@ header removed 2026-05-23 — двойная auth избыточна для pilo from __future__ import annotations import logging -from typing import Annotated +from typing import Annotated, Any -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.core.db import get_db +from app.services.etl.mapping_review import ( + approve_mapping, + list_mapping_review, + reject_mapping, +) from app.services.etl.objective_backfill import ( AUTO_ACCEPT_THRESHOLD, AUTO_ACCEPT_THRESHOLD_V2, @@ -157,3 +169,68 @@ def run_nspd_denorm_backfill( task = backfill_all_dumps.apply_async(kwargs={"limit": limit}) logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit) return {"task_id": task.id, "status": "enqueued", "limit": limit} + + +# ════════════════════════════════════════════════════════════════════════════ +# Mapping review — глазами подтвердить/отклонить авто-маппинги (feat/…-review) +# ════════════════════════════════════════════════════════════════════════════ + + +@router.get("/mapping-review") +def get_mapping_review( + db: Annotated[Session, Depends(get_db)], + only_unreviewed: Annotated[ + bool, Query(description="Только is_reviewed=false (авто-матчи без ревью)") + ] = True, + limit: Annotated[int, Query(ge=1, le=500, description="Строк на странице")] = 100, + offset: Annotated[int, Query(ge=0, description="Смещение страницы")] = 0, +) -> dict[str, Any]: + """Список строк objective_complex_mapping + обогащение для ревьюера. + + Обогащение каждой строки: + - domrf_comm_name / domrf_dev_name — latest snapshot по domrf_obj_id; + - objective_developers — застройщики objective-проекта (агрегат + objective_lots.developer, scoped по project_name). + + Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху). + + Returns dict: + rows: list обогащённых строк (id, objective_complex_name, + objective_project_id, objective_group, domrf_obj_id, match_method, + match_score, is_reviewed, note, created_at, domrf_comm_name, + domrf_dev_name, objective_developers); + total: всего строк (с учётом only_unreviewed, БЕЗ limit/offset); + limit / offset / only_unreviewed: эхо параметров. + """ + return list_mapping_review(db, only_unreviewed=only_unreviewed, limit=limit, offset=offset) + + +@router.post("/mapping-review/{mapping_id}/approve") +def approve_mapping_review( + mapping_id: int, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, Any]: + """Подтвердить маппинг: is_reviewed=true + append «approved » в note. + + 404 если строки с таким id нет. + """ + result = approve_mapping(db, mapping_id) + if result is None: + raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден") + return result + + +@router.post("/mapping-review/{mapping_id}/reject") +def reject_mapping_review( + mapping_id: int, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, Any]: + """Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2). + + Удаляемая строка целиком логируется (logger.info) перед commit — в note + писать некуда, строки не будет. 404 если id не найден. + """ + deleted = reject_mapping(db, mapping_id) + if deleted is None: + raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден") + return {"status": "deleted", "deleted": deleted} diff --git a/backend/app/services/etl/mapping_review.py b/backend/app/services/etl/mapping_review.py new file mode 100644 index 00000000..fa8fbed4 --- /dev/null +++ b/backend/app/services/etl/mapping_review.py @@ -0,0 +1,220 @@ +"""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 diff --git a/backend/tests/services/test_mapping_review.py b/backend/tests/services/test_mapping_review.py new file mode 100644 index 00000000..44a5b811 --- /dev/null +++ b/backend/tests/services/test_mapping_review.py @@ -0,0 +1,272 @@ +"""Тесты review-инструмента авто-маппингов (feat/mapping-review-page). + +Mock DB (unittest.mock) — не требует реального PostgreSQL. Покрывает: +- list_mapping_review: обогащение (domrf comm/dev + objective devs) + эхо total; +- approve_mapping: dict обновлённой строки; None (404) на несуществующий id; +- reject_mapping: dict удалённой строки (DELETE) + логирование; None на 404; +- endpoints approve/reject через TestClient: 200 + 404. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.db import get_db +from app.main import app +from app.services.etl.mapping_review import ( + approve_mapping, + list_mapping_review, + reject_mapping, +) + +_NOW = datetime(2026, 7, 3, 12, 0, tzinfo=UTC) + + +def _row(**kw: Any) -> SimpleNamespace: + """Row-заглушка с attribute-доступом (как SQLAlchemy Row).""" + defaults: dict[str, Any] = { + "id": 1, + "objective_complex_name": "Малевич", + "objective_project_id": 111, + "objective_group": "Екатеринбург", + "domrf_obj_id": 5001, + "match_method": "auto_core_dev_v5", + "match_score": 0.90, + "is_reviewed": False, + "note": "#2177 step2", + "created_at": _NOW, + "domrf_comm_name": 'ЖК "Малевич"', + "domrf_dev_name": "Брусника", + "objective_developers": ["Брусника", "СЗ Малевич"], + } + defaults.update(kw) + return SimpleNamespace(**defaults) + + +def _mock_db_for_list(rows: list[SimpleNamespace], total: int) -> MagicMock: + """MagicMock Session: первый execute (list) → .all(); второй (count) → scalar_one().""" + list_res = MagicMock() + list_res.all.return_value = rows + count_res = MagicMock() + count_res.scalar_one.return_value = total + + db = MagicMock() + db.execute.side_effect = [list_res, count_res] + return db + + +# ── list_mapping_review: обогащение + total ────────────────────────────────── + + +def test_list_enriches_and_counts() -> None: + """rows обогащены (domrf comm/dev + objective devs), total эхо-ится.""" + rows = [ + _row(id=1, match_score=0.80, is_reviewed=False), + _row( + id=2, + match_score=0.95, + is_reviewed=False, + domrf_obj_id=None, + domrf_comm_name=None, + domrf_dev_name=None, + objective_developers=None, + ), + ] + db = _mock_db_for_list(rows, total=14) + + out = list_mapping_review(db, only_unreviewed=True, limit=100, offset=0) + + assert out["total"] == 14 + assert out["limit"] == 100 + assert out["offset"] == 0 + assert out["only_unreviewed"] is True + assert len(out["rows"]) == 2 + + first = out["rows"][0] + assert first["id"] == 1 + assert first["match_score"] == 0.80 + assert first["domrf_comm_name"] == 'ЖК "Малевич"' + assert first["domrf_dev_name"] == "Брусника" + assert first["objective_developers"] == ["Брусника", "СЗ Малевич"] + assert first["created_at"] == _NOW.isoformat() + + # Строка без domrf-обогащения: None-поля + пустой список застройщиков. + second = out["rows"][1] + assert second["domrf_obj_id"] is None + assert second["domrf_comm_name"] is None + assert second["objective_developers"] == [] + + +def test_list_passes_filter_and_paging_params() -> None: + """only_unreviewed / limit / offset передаются в SQL-биндинги.""" + db = _mock_db_for_list([_row()], total=1) + + list_mapping_review(db, only_unreviewed=False, limit=25, offset=50) + + # Первый execute — list-SQL с биндингами страницы. + list_call = db.execute.call_args_list[0] + params = list_call.args[1] + assert params == {"only_unreviewed": False, "limit": 25, "offset": 50} + + # Второй execute — count-SQL с тем же фильтром. + count_call = db.execute.call_args_list[1] + assert count_call.args[1] == {"only_unreviewed": False} + + +def test_list_score_none_serialized_as_null() -> None: + """match_score=None (например legacy без скора) → None, не падает на float().""" + db = _mock_db_for_list([_row(match_score=None)], total=1) + out = list_mapping_review(db) + assert out["rows"][0]["match_score"] is None + + +# ── approve_mapping ────────────────────────────────────────────────────────── + + +def test_approve_updates_and_returns_row() -> None: + """approve → dict обновлённой строки, commit вызван.""" + updated = _row(is_reviewed=True, note="#2177 step2\napproved 2026-07-03") + res = MagicMock() + res.first.return_value = updated + db = MagicMock() + db.execute.return_value = res + + out = approve_mapping(db, mapping_id=1) + + assert out is not None + assert out["id"] == 1 + assert out["is_reviewed"] is True + assert "approved" in out["note"] + db.commit.assert_called_once() + db.rollback.assert_not_called() + + +def test_approve_missing_id_returns_none() -> None: + """approve несуществующего id → None (404 в роутере), rollback, без commit.""" + res = MagicMock() + res.first.return_value = None + db = MagicMock() + db.execute.return_value = res + + out = approve_mapping(db, mapping_id=999) + + assert out is None + db.rollback.assert_called_once() + db.commit.assert_not_called() + + +# ── reject_mapping (DELETE) ────────────────────────────────────────────────── + + +def test_reject_deletes_and_returns_row() -> None: + """reject → dict удалённой строки (эхо), commit вызван (DELETE зафиксирован).""" + deleted_row = _row(id=7, objective_complex_name="Отклонённый ЖК") + res = MagicMock() + res.first.return_value = deleted_row + db = MagicMock() + db.execute.return_value = res + + out = reject_mapping(db, mapping_id=7) + + assert out is not None + assert out["id"] == 7 + assert out["objective_complex_name"] == "Отклонённый ЖК" + assert out["match_method"] == "auto_core_dev_v5" + db.commit.assert_called_once() + db.rollback.assert_not_called() + + +def test_reject_missing_id_returns_none() -> None: + """reject несуществующего id → None, rollback, без commit/DELETE-фиксации.""" + res = MagicMock() + res.first.return_value = None + db = MagicMock() + db.execute.return_value = res + + out = reject_mapping(db, mapping_id=404) + + assert out is None + db.rollback.assert_called_once() + db.commit.assert_not_called() + + +# ── Endpoints (TestClient + get_db override) ───────────────────────────────── + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(app) + + +def _override_db(mock: MagicMock) -> None: + def _get_db_override() -> Any: + yield mock + + app.dependency_overrides[get_db] = _get_db_override + + +def test_endpoint_list_returns_payload(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """GET /mapping-review → 200 + payload из list_mapping_review.""" + _override_db(MagicMock()) + payload = { + "rows": [{"id": 1, "match_score": 0.80}], + "total": 14, + "limit": 100, + "offset": 0, + "only_unreviewed": True, + } + monkeypatch.setattr( + "app.api.v1.admin_etl.list_mapping_review", + lambda db, only_unreviewed, limit, offset: payload, + ) + r = client.get("/api/v1/admin/etl/mapping-review") + assert r.status_code == 200 + assert r.json()["total"] == 14 + + +def test_endpoint_approve_ok_and_404(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """POST approve → 200 при найденном id, 404 при None.""" + _override_db(MagicMock()) + + monkeypatch.setattr( + "app.api.v1.admin_etl.approve_mapping", + lambda db, mapping_id: {"id": mapping_id, "is_reviewed": True}, + ) + ok = client.post("/api/v1/admin/etl/mapping-review/1/approve") + assert ok.status_code == 200 + assert ok.json()["is_reviewed"] is True + + monkeypatch.setattr( + "app.api.v1.admin_etl.approve_mapping", + lambda db, mapping_id: None, + ) + missing = client.post("/api/v1/admin/etl/mapping-review/999/approve") + assert missing.status_code == 404 + + +def test_endpoint_reject_ok_and_404(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """POST reject → 200 {status: deleted} при найденном, 404 при None.""" + _override_db(MagicMock()) + + monkeypatch.setattr( + "app.api.v1.admin_etl.reject_mapping", + lambda db, mapping_id: {"id": mapping_id, "objective_complex_name": "X"}, + ) + ok = client.post("/api/v1/admin/etl/mapping-review/7/reject") + assert ok.status_code == 200 + body = ok.json() + assert body["status"] == "deleted" + assert body["deleted"]["id"] == 7 + + monkeypatch.setattr( + "app.api.v1.admin_etl.reject_mapping", + lambda db, mapping_id: None, + ) + missing = client.post("/api/v1/admin/etl/mapping-review/404/reject") + assert missing.status_code == 404 diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index b2eaa04d..108f4e38 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -9,6 +9,7 @@ const TABS = [ { href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" }, { href: "/admin/scrape/cadastre", label: "🏗 Cadastre bulk harvest" }, { href: "/admin/scrape/objective", label: "ETL Objective" }, + { href: "/admin/mapping-review", label: "Ревью маппингов" }, { href: "/admin/leads", label: "Лиды PRINZIP" }, ]; diff --git a/frontend/src/app/admin/mapping-review/page.tsx b/frontend/src/app/admin/mapping-review/page.tsx new file mode 100644 index 00000000..47596a35 --- /dev/null +++ b/frontend/src/app/admin/mapping-review/page.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { Badge } from "@/components/ui/Badge"; +import { apiFetch } from "@/lib/api"; +import { cardStyle as cardStyleBase, td, th } from "@/lib/adminStyles"; + +const cardStyle = { ...cardStyleBase, marginTop: 16 }; + +// ── Типы ответа GET /api/v1/admin/etl/mapping-review ────────────────────────── +interface MappingReviewRow { + id: number; + objective_complex_name: string | null; + objective_project_id: number | null; + objective_group: string | null; + domrf_obj_id: number | null; + match_method: string | null; + match_score: number | null; + is_reviewed: boolean; + note: string | null; + created_at: string | null; + domrf_comm_name: string | null; + domrf_dev_name: string | null; + objective_developers: string[]; +} + +interface MappingReviewResponse { + rows: MappingReviewRow[]; + total: number; + limit: number; + offset: number; + only_unreviewed: boolean; +} + +const LIMIT = 100; + +/** + * Грубая нормализация ЯДРА имени для attention-подсветки (НЕ решение о + * маппинге — только сигнал глазам). lower + убрать «жк», кавычки/скобки, + * пунктуацию, схлопнуть пробелы. Если ядро objective НЕ содержится в ядре + * domrf comm_name (и наоборот) — строка подсвечивается --warn-soft. + */ +function normCore(raw: string | null | undefined): string { + if (!raw) return ""; + return raw + .toLowerCase() + .replace(/[«»"'`“”()[]\[\]]/g, " ") + .replace(/\bжк\b/g, " ") + .replace(/\bжилой\s+комплекс\b/g, " ") + .replace(/[^0-9a-zа-яё]+/gi, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** true если ядра явно расходятся (ни одно не содержит другое) → внимание. */ +function coresMismatch( + objName: string | null, + domrfName: string | null, +): boolean { + const a = normCore(objName); + const b = normCore(domrfName); + // Нет одной из сторон для сравнения — не флагаем (нечего сравнивать). + if (!a || !b) return false; + if (a === b) return false; + return !a.includes(b) && !b.includes(a); +} + +function formatIso(s: string | null): string { + if (!s) return "—"; + return new Date(s).toLocaleDateString("ru-RU"); +} + +function formatScore(v: number | null): string { + if (v == null) return "—"; + return v.toFixed(3); +} + +export default function MappingReviewPage() { + const [onlyUnreviewed, setOnlyUnreviewed] = useState(true); + const [offset, setOffset] = useState(0); + const [confirmReject, setConfirmReject] = useState( + null, + ); + const queryClient = useQueryClient(); + + const listKey = ["mapping-review", onlyUnreviewed, offset] as const; + + const list = useQuery({ + queryKey: listKey, + queryFn: () => { + const qs = new URLSearchParams({ + only_unreviewed: String(onlyUnreviewed), + limit: String(LIMIT), + offset: String(offset), + }); + return apiFetch( + `/api/v1/admin/etl/mapping-review?${qs.toString()}`, + ); + }, + }); + + const invalidateList = () => + queryClient.invalidateQueries({ queryKey: ["mapping-review"] }); + + const approve = useMutation({ + mutationFn: (id: number) => + apiFetch(`/api/v1/admin/etl/mapping-review/${id}/approve`, { + method: "POST", + }), + onSuccess: () => void invalidateList(), + }); + + const reject = useMutation({ + mutationFn: (id: number) => + apiFetch(`/api/v1/admin/etl/mapping-review/${id}/reject`, { + method: "POST", + }), + onSuccess: () => { + setConfirmReject(null); + void invalidateList(); + }, + }); + + const rows = list.data?.rows ?? []; + const total = list.data?.total ?? 0; + const pageFrom = total === 0 ? 0 : offset + 1; + const pageTo = Math.min(offset + LIMIT, total); + const canPrev = offset > 0; + const canNext = offset + LIMIT < total; + + // id строки, по которой сейчас идёт мутация — для локального disabled. + const pendingApproveId = approve.isPending ? approve.variables : null; + const pendingRejectId = reject.isPending ? reject.variables : null; + + return ( + <> +

+ Ревью авто-маппингов ЖК → DOM.РФ +

+

+ Строки objective_complex_mapping, связавшие + objective-проект с объектом DOM.РФ автоматически (fuzzy / core-dev / + geo). Сомнительные — сверху (сорт с бэка). Подтвердить{" "} + помечает маппинг проверенным. Отклонить удаляет строку + — сделки этого комплекса исчезнут из §4.2. Строки, где ядро + objective-имени не совпадает с ядром DOM.РФ, подсвечены жёлтым для + внимания (эвристика, не приговор). +

+ + {/* Ошибка загрузки списка */} + {list.isError && ( +
+ Ошибка загрузки списка: {(list.error as Error).message} +
+ )} + + {/* Фильтр + пагинация header */} +
+
+ +
+ + {list.isLoading ? ( + "Загрузка…" + ) : ( + <> + {pageFrom}–{pageTo} из{" "} + {total.toLocaleString("ru")} + + )} + + + +
+
+
+ + {/* Ошибка мутаций (inline, не привязана к строке) */} + {(approve.isError || reject.isError) && ( +
+ {approve.isError && + `Не удалось подтвердить: ${(approve.error as Error).message}`} + {reject.isError && + `Не удалось отклонить: ${(reject.error as Error).message}`} +
+ )} + + {/* Таблица */} +
+ {!list.isLoading && rows.length === 0 ? ( +

+ {onlyUnreviewed + ? "Непроверенных маппингов нет — всё отревьюено." + : "Маппингов не найдено."} +

+ ) : ( +
+ + + + {[ + "Объектив (ЖК)", + "Объектив-девелоперы", + "DOM.РФ имя", + "DOM.РФ застройщик", + "Метод", + "Score", + "Note", + "Дата", + "Действия", + ].map((h) => ( + + ))} + + + + {rows.map((r) => { + const attention = coresMismatch( + r.objective_complex_name, + r.domrf_comm_name, + ); + const rowApproving = pendingApproveId === r.id; + const rowRejecting = pendingRejectId === r.id; + const rowBusy = rowApproving || rowRejecting; + return ( + + + + + + + + + + + + ); + })} + +
+ {h} +
+ + {r.objective_complex_name ?? "—"} + + {r.objective_group ? ( +
+ {r.objective_group} +
+ ) : null} +
+ {r.objective_developers.length > 0 ? ( + + {r.objective_developers.join(", ")} + + ) : ( + + )} + + + {r.domrf_comm_name ?? ( + + )} + + {r.domrf_obj_id != null ? ( +
+ obj {r.domrf_obj_id} +
+ ) : null} +
+ + {r.domrf_dev_name ?? ( + + )} + + + + {r.match_method ?? "—"} + + + {formatScore(r.match_score)} + + {r.note ?? "—"} + + {formatIso(r.created_at)} + +
+ {r.is_reviewed ? ( + + проверен + + ) : ( + + )} + +
+
+
+ )} +
+ + {/* Confirm-диалог отклонения */} + {confirmReject && ( +
{ + if (!reject.isPending) setConfirmReject(null); + }} + > +
e.stopPropagation()} + > +

+ Отклонить маппинг? +

+

+ Маппинг будет удалён — сделки этого комплекса + исчезнут из §4.2. Точно? +

+

+ {confirmReject.objective_complex_name ?? "—"} →{" "} + {confirmReject.domrf_comm_name ?? "—"} +

+ {reject.isError && ( +

+ Ошибка: {(reject.error as Error).message} +

+ )} +
+ + +
+
+
+ )} + + ); +} + +// ── Стили (admin inline-паттерн, см. adminStyles) ───────────────────────────── + +const ellipsis: React.CSSProperties = { + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +const pageBtn = (disabled: boolean): React.CSSProperties => ({ + padding: "6px 12px", + background: "#f3f4f6", + color: "#1f2937", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 14, + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.5 : 1, +}); + +const successBtn = (disabled: boolean): React.CSSProperties => ({ + padding: "5px 10px", + background: "#0a7a3a", + color: "#fff", + border: "none", + borderRadius: 6, + fontSize: 12, + fontWeight: 600, + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.6 : 1, + whiteSpace: "nowrap", +}); + +const dangerBtn = (disabled: boolean): React.CSSProperties => ({ + padding: "5px 10px", + background: "#b3261e", + color: "#fff", + border: "none", + borderRadius: 6, + fontSize: 12, + fontWeight: 600, + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.6 : 1, + whiteSpace: "nowrap", +}); + +const secondaryBtn = (disabled: boolean): React.CSSProperties => ({ + padding: "8px 14px", + background: "#f3f4f6", + color: "#1f2937", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 13, + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.6 : 1, +});