feat: ревью-инструмент авто-маппингов Объектив↔ДОМ.РФ (/admin/mapping-review) #2278
6 changed files with 1327 additions and 2 deletions
|
|
@ -9,6 +9,13 @@ POST /api/v1/admin/etl/objective-backfill
|
||||||
POST /api/v1/admin/etl/nspd-denorm-backfill
|
POST /api/v1/admin/etl/nspd-denorm-backfill
|
||||||
Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps.
|
Запустить 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
|
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
|
||||||
header removed 2026-05-23 — двойная auth избыточна для pilot.
|
header removed 2026-05-23 — двойная auth избыточна для pilot.
|
||||||
"""
|
"""
|
||||||
|
|
@ -16,12 +23,17 @@ header removed 2026-05-23 — двойная auth избыточна для pilo
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
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 (
|
from app.services.etl.objective_backfill import (
|
||||||
AUTO_ACCEPT_THRESHOLD,
|
AUTO_ACCEPT_THRESHOLD,
|
||||||
AUTO_ACCEPT_THRESHOLD_V2,
|
AUTO_ACCEPT_THRESHOLD_V2,
|
||||||
|
|
@ -157,3 +169,68 @@ def run_nspd_denorm_backfill(
|
||||||
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
|
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
|
||||||
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
|
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
|
||||||
return {"task_id": task.id, "status": "enqueued", "limit": 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}
|
||||||
|
|
|
||||||
220
backend/app/services/etl/mapping_review.py
Normal file
220
backend/app/services/etl/mapping_review.py
Normal 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
|
||||||
272
backend/tests/services/test_mapping_review.py
Normal file
272
backend/tests/services/test_mapping_review.py
Normal 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
|
||||||
|
|
@ -9,6 +9,7 @@ const TABS = [
|
||||||
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
|
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
|
||||||
{ href: "/admin/scrape/cadastre", label: "🏗 Cadastre bulk harvest" },
|
{ href: "/admin/scrape/cadastre", label: "🏗 Cadastre bulk harvest" },
|
||||||
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
||||||
|
{ href: "/admin/mapping-review", label: "Ревью маппингов" },
|
||||||
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
571
frontend/src/app/admin/mapping-review/page.tsx
Normal file
571
frontend/src/app/admin/mapping-review/page.tsx
Normal 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,
|
||||||
|
});
|
||||||
|
|
@ -2443,6 +2443,86 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/trade-in/estimate": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
estimate_api_v1_trade_in_estimate_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue