feat: ревью-инструмент авто-маппингов Объектив↔ДОМ.РФ (/admin/mapping-review) #2278

Merged
bot-backend merged 2 commits from feat/mapping-review-page into main 2026-07-03 07:59:14 +00:00
6 changed files with 1327 additions and 2 deletions

View file

@ -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 <date>» в 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}

View file

@ -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 <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

View file

@ -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

View file

@ -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" },
];

View file

@ -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<MappingReviewRow | null>(
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<MappingReviewResponse>(
`/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 (
<>
<h2 style={{ marginTop: 0, fontSize: 18 }}>
Ревью авто-маппингов ЖК DOM.РФ
</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Строки <code>objective_complex_mapping</code>, связавшие
objective-проект с объектом DOM.РФ автоматически (fuzzy / core-dev /
geo). Сомнительные сверху (сорт с бэка). <strong>Подтвердить</strong>{" "}
помечает маппинг проверенным. <strong>Отклонить</strong> удаляет строку
сделки этого комплекса исчезнут из §4.2. Строки, где ядро
objective-имени не совпадает с ядром DOM.РФ, подсвечены жёлтым для
внимания (эвристика, не приговор).
</p>
{/* Ошибка загрузки списка */}
{list.isError && (
<div
style={{
marginTop: 16,
padding: 16,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
fontSize: 13,
}}
>
Ошибка загрузки списка: {(list.error as Error).message}
</div>
)}
{/* Фильтр + пагинация header */}
<section style={cardStyle}>
<div
style={{
display: "flex",
gap: 16,
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "#374151",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={onlyUnreviewed}
onChange={(e) => {
setOnlyUnreviewed(e.target.checked);
setOffset(0);
}}
/>
Только непроверенные
</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
fontSize: 13,
color: "#5b6066",
}}
>
<span>
{list.isLoading ? (
"Загрузка…"
) : (
<>
{pageFrom}{pageTo} из{" "}
<strong>{total.toLocaleString("ru")}</strong>
</>
)}
</span>
<button
type="button"
onClick={() => setOffset((o) => Math.max(0, o - LIMIT))}
disabled={!canPrev}
style={pageBtn(!canPrev)}
aria-label="Предыдущая страница"
>
</button>
<button
type="button"
onClick={() => setOffset((o) => o + LIMIT)}
disabled={!canNext}
style={pageBtn(!canNext)}
aria-label="Следующая страница"
>
</button>
</div>
</div>
</section>
{/* Ошибка мутаций (inline, не привязана к строке) */}
{(approve.isError || reject.isError) && (
<div
style={{
marginTop: 16,
padding: 12,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
fontSize: 13,
}}
>
{approve.isError &&
`Не удалось подтвердить: ${(approve.error as Error).message}`}
{reject.isError &&
`Не удалось отклонить: ${(reject.error as Error).message}`}
</div>
)}
{/* Таблица */}
<section style={cardStyle}>
{!list.isLoading && rows.length === 0 ? (
<p style={{ color: "#5b6066", fontSize: 13, margin: 0 }}>
{onlyUnreviewed
? "Непроверенных маппингов нет — всё отревьюено."
: "Маппингов не найдено."}
</p>
) : (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"Объектив (ЖК)",
"Объектив-девелоперы",
"DOM.РФ имя",
"DOM.РФ застройщик",
"Метод",
"Score",
"Note",
"Дата",
"Действия",
].map((h) => (
<th key={h} style={th}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{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 (
<tr
key={r.id}
style={{
borderBottom: "1px solid #eef0f3",
background: attention ? "var(--warn-soft)" : "#fff",
}}
>
<td style={{ ...td, maxWidth: 260 }}>
<span
style={{ fontWeight: 500 }}
title={r.objective_complex_name ?? ""}
>
{r.objective_complex_name ?? "—"}
</span>
{r.objective_group ? (
<div style={{ fontSize: 11, color: "#73767e" }}>
{r.objective_group}
</div>
) : null}
</td>
<td
style={{ ...td, maxWidth: 200, fontSize: 12 }}
title={r.objective_developers.join(", ")}
>
{r.objective_developers.length > 0 ? (
<span style={ellipsis}>
{r.objective_developers.join(", ")}
</span>
) : (
<span style={{ color: "#9ca3af" }}></span>
)}
</td>
<td
style={{ ...td, maxWidth: 280 }}
title={r.domrf_comm_name ?? ""}
>
<span style={ellipsis}>
{r.domrf_comm_name ?? (
<span style={{ color: "#9ca3af" }}></span>
)}
</span>
{r.domrf_obj_id != null ? (
<div
style={{
fontSize: 11,
color: "#73767e",
fontFamily: "monospace",
}}
>
obj {r.domrf_obj_id}
</div>
) : null}
</td>
<td
style={{ ...td, maxWidth: 200, fontSize: 12 }}
title={r.domrf_dev_name ?? ""}
>
<span style={ellipsis}>
{r.domrf_dev_name ?? (
<span style={{ color: "#9ca3af" }}></span>
)}
</span>
</td>
<td style={{ ...td, fontSize: 12 }}>
<span style={{ fontFamily: "monospace" }}>
{r.match_method ?? "—"}
</span>
</td>
<td
style={{
...td,
fontFamily: "monospace",
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{formatScore(r.match_score)}
</td>
<td
style={{
...td,
maxWidth: 160,
fontSize: 12,
color: "#5b6066",
}}
title={r.note ?? ""}
>
<span style={ellipsis}>{r.note ?? "—"}</span>
</td>
<td style={{ ...td, whiteSpace: "nowrap", fontSize: 12 }}>
{formatIso(r.created_at)}
</td>
<td style={{ ...td, whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6 }}>
{r.is_reviewed ? (
<Badge variant="success" size="sm">
проверен
</Badge>
) : (
<button
type="button"
onClick={() => approve.mutate(r.id)}
disabled={rowBusy}
style={successBtn(rowBusy)}
>
{rowApproving ? "…" : "Подтвердить"}
</button>
)}
<button
type="button"
onClick={() => setConfirmReject(r)}
disabled={rowBusy}
style={dangerBtn(rowBusy)}
>
{rowRejecting ? "…" : "Отклонить"}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{/* Confirm-диалог отклонения */}
{confirmReject && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(15, 23, 42, 0.45)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
padding: 16,
}}
onClick={() => {
if (!reject.isPending) setConfirmReject(null);
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 24,
maxWidth: 460,
width: "100%",
boxShadow: "0 10px 40px rgba(0,0,0,0.2)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 600 }}>
Отклонить маппинг?
</h3>
<p style={{ margin: "0 0 8px", fontSize: 13, color: "#374151" }}>
Маппинг будет <strong>удалён</strong> сделки этого комплекса
исчезнут из §4.2. Точно?
</p>
<p
style={{
margin: "0 0 20px",
fontSize: 12,
color: "#5b6066",
background: "#f6f7f9",
borderRadius: 6,
padding: "8px 10px",
}}
>
<strong>{confirmReject.objective_complex_name ?? "—"}</strong> {" "}
{confirmReject.domrf_comm_name ?? "—"}
</p>
{reject.isError && (
<p style={{ margin: "0 0 12px", fontSize: 12, color: "#b3261e" }}>
Ошибка: {(reject.error as Error).message}
</p>
)}
<div
style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}
>
<button
type="button"
onClick={() => setConfirmReject(null)}
disabled={reject.isPending}
style={secondaryBtn(reject.isPending)}
>
Отмена
</button>
<button
type="button"
onClick={() => reject.mutate(confirmReject.id)}
disabled={reject.isPending}
style={dangerBtn(reject.isPending)}
>
{reject.isPending ? "Удаление…" : "Отклонить и удалить"}
</button>
</div>
</div>
</div>
)}
</>
);
}
// ── Стили (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,
});

View file

@ -2443,6 +2443,86 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/etl/mapping-review": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Mapping Review
* @description Список строк 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: эхо параметров.
*/
get: operations["get_mapping_review_api_v1_admin_etl_mapping_review_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/etl/mapping-review/{mapping_id}/approve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Approve Mapping Review
* @description Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
*
* 404 если строки с таким id нет.
*/
post: operations["approve_mapping_review_api_v1_admin_etl_mapping_review__mapping_id__approve_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/etl/mapping-review/{mapping_id}/reject": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Reject Mapping Review
* @description Отклонить маппинг DELETE строки (иначе травит mv_layout_velocity §4.2).
*
* Удаляемая строка целиком логируется (logger.info) перед commit в note
* писать некуда, строки не будет. 404 если id не найден.
*/
post: operations["reject_mapping_review_api_v1_admin_etl_mapping_review__mapping_id__reject_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trade-in/estimate": {
parameters: {
query?: never;
@ -9191,6 +9271,110 @@ export interface operations {
};
};
};
get_mapping_review_api_v1_admin_etl_mapping_review_get: {
parameters: {
query?: {
/** @description Только is_reviewed=false (авто-матчи без ревью) */
only_unreviewed?: boolean;
/** @description Строк на странице */
limit?: number;
/** @description Смещение страницы */
offset?: number;
};
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"];
};
};
};
};
approve_mapping_review_api_v1_admin_etl_mapping_review__mapping_id__approve_post: {
parameters: {
query?: never;
header?: never;
path: {
mapping_id: number;
};
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"];
};
};
};
};
reject_mapping_review_api_v1_admin_etl_mapping_review__mapping_id__reject_post: {
parameters: {
query?: never;
header?: never;
path: {
mapping_id: number;
};
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"];
};
};
};
};
estimate_api_v1_trade_in_estimate_post: {
parameters: {
query?: never;