feat(db): canonical complexes + unified views + naming consistency
* Task 2+3: complexes.obj_class и district_name из Objective
(350 ЖК с классом, 1525 с district; objective приоритетнее)
* Task 1 (fuzzy match orphan Objective ↔ domrf_kn):
- pg_trgm extension
- auto-merge 14 high-confidence (similarity ≥ 0.85)
- v_complex_match_review для остальных 82 кандидатов
- complexes 1752 → 1740 · cross-source 114 → 124
* Task 4 (naming unification без RENAME COLUMN):
- GENERATED ALWAYS AS (...) STORED для синонимов
- region_id в 15 таблиц + 9 partitions
- developer_id в 7, developer_name в 5
- COMMENT 'DEPRECATED' на region_cd/region_code/dev_id/dev_name
* Task 5 (unified scrape dashboard):
- GET /api/v1/admin/scrape/all/runs + /all/logs (поверх v_scrape_runs_unified)
- new page /admin/scrape/all с filter табами + 4 stat-плитки
- +tab «📊 Все скраперы» в admin layout
Файлы: data/sql/75_unification_naming.sql · backend/app/api/v1/admin_scrape.py
· frontend/src/app/admin/scrape/all/page.tsx · admin/layout.tsx
This commit is contained in:
parent
20625f78f3
commit
52bcf9d30c
6 changed files with 1348 additions and 0 deletions
|
|
@ -801,6 +801,118 @@ def objective_coverage(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unified scrape dashboard (поверх v_scrape_runs_unified / v_scrape_log_unified) ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all/runs")
|
||||||
|
def list_all_runs(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
scraper_type: str | None = None,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Унифицированный список прогонов (kn + nspd + objective).
|
||||||
|
|
||||||
|
Поверх v_scrape_runs_unified. Если scraper_type не задан — все 3.
|
||||||
|
Используется главной dashboard /admin/scrape/all.
|
||||||
|
"""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
where = "" if not scraper_type else "WHERE scraper_type = :st"
|
||||||
|
params: dict[str, Any] = {"lim": min(limit, 200)}
|
||||||
|
if scraper_type:
|
||||||
|
params["st"] = scraper_type
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT scraper_type, run_id, started_at, finished_at, heartbeat_at,
|
||||||
|
status, error, triggered_by, scope, requests_count,
|
||||||
|
items_ok, items_failed, sub_items_ok,
|
||||||
|
progress_idx, progress_total, extra
|
||||||
|
FROM v_scrape_runs_unified
|
||||||
|
{where}
|
||||||
|
ORDER BY started_at DESC NULLS LAST
|
||||||
|
LIMIT :lim
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"scraper_type": r["scraper_type"],
|
||||||
|
"run_id": r["run_id"],
|
||||||
|
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
|
||||||
|
"finished_at": r["finished_at"].isoformat() if r["finished_at"] else None,
|
||||||
|
"heartbeat_at": r["heartbeat_at"].isoformat() if r["heartbeat_at"] else None,
|
||||||
|
"status": r["status"],
|
||||||
|
"error": r["error"],
|
||||||
|
"triggered_by": r["triggered_by"],
|
||||||
|
"scope": r["scope"],
|
||||||
|
"requests_count": r["requests_count"],
|
||||||
|
"items_ok": r["items_ok"],
|
||||||
|
"items_failed": r["items_failed"],
|
||||||
|
"sub_items_ok": r["sub_items_ok"],
|
||||||
|
"progress_idx": r["progress_idx"],
|
||||||
|
"progress_total": r["progress_total"],
|
||||||
|
"extra": r["extra"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all/logs")
|
||||||
|
def list_all_logs(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
scraper_type: str | None = None,
|
||||||
|
run_id: int | None = None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
where: list[str] = []
|
||||||
|
params: dict[str, Any] = {"lim": min(limit, 1000)}
|
||||||
|
if scraper_type:
|
||||||
|
where.append("scraper_type = :st")
|
||||||
|
params["st"] = scraper_type
|
||||||
|
if run_id is not None:
|
||||||
|
where.append("run_id = :rid")
|
||||||
|
params["rid"] = run_id
|
||||||
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT scraper_type, log_id, run_id, ts, level, stage, entity_id, message
|
||||||
|
FROM v_scrape_log_unified
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY log_id DESC
|
||||||
|
LIMIT :lim
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"scraper_type": r["scraper_type"],
|
||||||
|
"log_id": r["log_id"],
|
||||||
|
"run_id": r["run_id"],
|
||||||
|
"ts": r["ts"].isoformat() if r["ts"] else None,
|
||||||
|
"level": r["level"],
|
||||||
|
"stage": r["stage"],
|
||||||
|
"entity_id": r["entity_id"],
|
||||||
|
"message": r["message"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ── DomRF (kn) — runs list ───────────────────────────────────────────────────
|
# ── DomRF (kn) — runs list ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
318
data/sql/73_complexes_master.sql
Normal file
318
data/sql/73_complexes_master.sql
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
-- ЖК как canonical master entity + cross-ref на источники.
|
||||||
|
--
|
||||||
|
-- Зачем:
|
||||||
|
-- До этого жил мир из 5+ таблиц с дублирующейся семантикой "ЖК":
|
||||||
|
-- - domrf_kn_objects (4548, ДОМ.РФ kn-API)
|
||||||
|
-- - objective_lots.project_name (350 уникальных, Objective API)
|
||||||
|
-- - objective_corpus_room_month.project_name (тот же, 350)
|
||||||
|
-- - objective_complex_mapping (114 fuzzy matches → domrf_obj_id)
|
||||||
|
-- - cad_buildings.cad_num (косвенно через NSPD)
|
||||||
|
-- - rosreestr_deals.complex_name (текстовое поле в сделках)
|
||||||
|
-- Каждый источник называет ЖК по-своему ("ЖК \"Парк Культуры\"" vs
|
||||||
|
-- "Парк Культуры" vs domrf_obj_id=66845). recommend_mix /
|
||||||
|
-- velocity-anomaly / cross-source analytics требуют одного entity.
|
||||||
|
--
|
||||||
|
-- Архитектура:
|
||||||
|
-- complexes (master) — одна строка = один реальный ЖК
|
||||||
|
-- complex_sources (cross-ref) — many-to-many ЖК ↔ источник + source_id
|
||||||
|
--
|
||||||
|
-- Стратегия backfill:
|
||||||
|
-- 1. Каждый domrf_kn_objects = entity в complexes (4548 ЖК)
|
||||||
|
-- + complex_sources(source='domrf_kn', source_id=obj_id)
|
||||||
|
-- 2. Сматченные objective project_name (114) → существующий complex по
|
||||||
|
-- objective_complex_mapping.domrf_obj_id
|
||||||
|
-- + complex_sources(source='objective', source_id=project_name)
|
||||||
|
-- 3. Unmatched objective project_name (236) → новые entity в complexes
|
||||||
|
-- + complex_sources(source='objective')
|
||||||
|
--
|
||||||
|
-- Идемпотентно через ON CONFLICT.
|
||||||
|
|
||||||
|
-- ── 1. complexes (canonical master) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS complexes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
-- Display
|
||||||
|
canonical_name TEXT NOT NULL, -- "Парк Культуры" / "ЖК Авангард 3.0"
|
||||||
|
developer_name TEXT, -- "PRINZIP" / "ГК КОРТРОС"
|
||||||
|
developer_id TEXT, -- FK on domrf_developers.developer_id (nullable)
|
||||||
|
-- Geo
|
||||||
|
region_id INTEGER, -- 66 для Свердл.обл (FK on domrf_regions.region_id)
|
||||||
|
district_name TEXT, -- "Академический" / "Автовокзал"
|
||||||
|
address TEXT,
|
||||||
|
latitude NUMERIC,
|
||||||
|
longitude NUMERIC,
|
||||||
|
geom geometry(Point, 4326), -- spatial для ST_DWithin / spatial join
|
||||||
|
-- Spec
|
||||||
|
obj_class TEXT, -- "комфорт" / "бизнес" / "стандарт" / "элитный"
|
||||||
|
flat_count INTEGER, -- общее ПД
|
||||||
|
square_living NUMERIC, -- общая жилая площадь
|
||||||
|
floor_min INTEGER,
|
||||||
|
floor_max INTEGER,
|
||||||
|
wall_type TEXT, -- "монолит" / "кирпич" / etc.
|
||||||
|
-- Stage
|
||||||
|
sales_start_date DATE,
|
||||||
|
plan_completion_date DATE,
|
||||||
|
actual_completion_date DATE,
|
||||||
|
site_status TEXT, -- "строящийся" / "сдан" / "архив"
|
||||||
|
obj_status INTEGER, -- 0/1/2 кодировка ДОМ.РФ
|
||||||
|
is_problem BOOLEAN DEFAULT FALSE,
|
||||||
|
has_escrow BOOLEAN, -- эскроу-проект
|
||||||
|
-- Audit
|
||||||
|
primary_source TEXT NOT NULL DEFAULT 'domrf_kn', -- какой источник был основой
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS complexes_name_idx
|
||||||
|
ON complexes(canonical_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS complexes_developer_idx
|
||||||
|
ON complexes(developer_id) WHERE developer_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS complexes_region_district_idx
|
||||||
|
ON complexes(region_id, district_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS complexes_class_idx
|
||||||
|
ON complexes(obj_class) WHERE obj_class IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS complexes_geom_idx
|
||||||
|
ON complexes USING GIST (geom) WHERE geom IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS complexes_status_idx
|
||||||
|
ON complexes(site_status) WHERE site_status IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE complexes IS
|
||||||
|
'Canonical master entity для ЖК. Один реальный жилой комплекс = одна строка. '
|
||||||
|
'Связи на разные источники (domrf_kn, objective, rosreestr, etc.) — через '
|
||||||
|
'complex_sources. Все analytics-запросы должны идти через эту таблицу '
|
||||||
|
'вместо direct lookup в источниках.';
|
||||||
|
|
||||||
|
-- ── 2. complex_sources (cross-ref) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS complex_sources (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
complex_id BIGINT NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
|
||||||
|
source TEXT NOT NULL, -- 'domrf_kn' | 'objective' | 'rosreestr' | 'cad_buildings' | 'yandex' | ...
|
||||||
|
source_id TEXT NOT NULL, -- obj_id::text / project_name / cad_num / external_id
|
||||||
|
source_external_id BIGINT, -- для int-ID источников (objective.project_id, domrf.obj_id)
|
||||||
|
-- Match audit
|
||||||
|
match_method TEXT, -- 'exact_name' | 'fuzzy_name' | 'spatial' | 'manual' | 'imported'
|
||||||
|
match_score NUMERIC, -- 0..1 для fuzzy
|
||||||
|
is_reviewed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
note TEXT,
|
||||||
|
linked_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (source, source_id) -- один (источник, source_id) — на один ЖК
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS complex_sources_complex_idx
|
||||||
|
ON complex_sources(complex_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS complex_sources_source_idx
|
||||||
|
ON complex_sources(source);
|
||||||
|
CREATE INDEX IF NOT EXISTS complex_sources_external_id_idx
|
||||||
|
ON complex_sources(source, source_external_id) WHERE source_external_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS complex_sources_unreviewed_idx
|
||||||
|
ON complex_sources(is_reviewed) WHERE is_reviewed = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE complex_sources IS
|
||||||
|
'Cross-ref many-to-many: ЖК (complexes) ↔ его представления в источниках. '
|
||||||
|
'Один реальный ЖК может иметь записи в нескольких источниках — здесь '
|
||||||
|
'хранятся все эти ссылки. UNIQUE(source, source_id) гарантирует что один '
|
||||||
|
'(источник, ID-в-источнике) присвоен ровно одному ЖК.';
|
||||||
|
|
||||||
|
-- ── 3. Backfill из domrf_kn_objects ─────────────────────────────────────────
|
||||||
|
|
||||||
|
-- ВАЖНО: domrf_kn_objects содержит несколько snapshot'ов на один obj_id
|
||||||
|
-- (4548 rows = 1516 уникальных obj_id × ~3 snapshot_date). Поэтому
|
||||||
|
-- DISTINCT ON (obj_id) ORDER BY snapshot_date DESC — берём свежий snapshot.
|
||||||
|
--
|
||||||
|
-- ВНИМАНИЕ: domrf_kn_objects.obj_class = NULL во всех строках (известный
|
||||||
|
-- баг kn-API), поэтому obj_class останется NULL — он подтянется когда
|
||||||
|
-- сделаем UPDATE из objective_lots (там obj_class заполнен).
|
||||||
|
|
||||||
|
WITH latest_kn AS (
|
||||||
|
SELECT DISTINCT ON (obj_id)
|
||||||
|
obj_id, comm_name, addr, region_cd, dev_id, dev_name, district_name,
|
||||||
|
latitude, longitude, obj_class, flat_count, square_living,
|
||||||
|
floor_min, floor_max, wall_type, ready_dt,
|
||||||
|
site_status, obj_status, is_problem, escrow
|
||||||
|
FROM domrf_kn_objects
|
||||||
|
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||||
|
),
|
||||||
|
inserted_complexes AS (
|
||||||
|
INSERT INTO complexes (
|
||||||
|
canonical_name, developer_name, developer_id, region_id, district_name,
|
||||||
|
address, latitude, longitude, geom,
|
||||||
|
obj_class, flat_count, square_living, floor_min, floor_max, wall_type,
|
||||||
|
plan_completion_date, site_status, obj_status, is_problem, has_escrow,
|
||||||
|
primary_source
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(comm_name, addr, 'unknown'),
|
||||||
|
dev_name, dev_id, region_cd, district_name,
|
||||||
|
addr, latitude, longitude,
|
||||||
|
CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL
|
||||||
|
THEN ST_SetSRID(ST_MakePoint(longitude, latitude), 4326) ELSE NULL END,
|
||||||
|
obj_class, flat_count, square_living, floor_min, floor_max, wall_type,
|
||||||
|
ready_dt, site_status, obj_status, COALESCE(is_problem, FALSE), escrow,
|
||||||
|
'domrf_kn'
|
||||||
|
FROM latest_kn
|
||||||
|
RETURNING id, canonical_name
|
||||||
|
)
|
||||||
|
INSERT INTO complex_sources (
|
||||||
|
complex_id, source, source_id, source_external_id,
|
||||||
|
match_method, match_score, is_reviewed, note
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ic.id, 'domrf_kn', lk.obj_id::text, lk.obj_id, 'imported', 1.0, TRUE,
|
||||||
|
'auto-import from domrf_kn_objects (latest snapshot per obj_id)'
|
||||||
|
FROM latest_kn lk
|
||||||
|
JOIN inserted_complexes ic
|
||||||
|
ON ic.canonical_name = COALESCE(lk.comm_name, lk.addr, 'unknown')
|
||||||
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 4. Backfill из objective_complex_mapping (114 сматченных) ───────────────
|
||||||
|
|
||||||
|
-- Эти project_name уже привязаны к domrf_obj_id — линкуем к существующему complex.
|
||||||
|
INSERT INTO complex_sources (
|
||||||
|
complex_id, source, source_id, source_external_id,
|
||||||
|
match_method, match_score, is_reviewed, note
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cs_kn.complex_id, -- complex по domrf_kn
|
||||||
|
'objective',
|
||||||
|
m.objective_complex_name,
|
||||||
|
m.objective_project_id,
|
||||||
|
m.match_method,
|
||||||
|
m.match_score,
|
||||||
|
m.is_reviewed,
|
||||||
|
'imported from objective_complex_mapping'
|
||||||
|
FROM objective_complex_mapping m
|
||||||
|
JOIN complex_sources cs_kn
|
||||||
|
ON cs_kn.source = 'domrf_kn'
|
||||||
|
AND cs_kn.source_external_id = m.domrf_obj_id
|
||||||
|
WHERE m.domrf_obj_id IS NOT NULL
|
||||||
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 5. Backfill orphan-проектов из objective_lots (236 unmatched) ───────────
|
||||||
|
|
||||||
|
-- Для каждого project_name которого нет ни в complex_sources(source=objective)
|
||||||
|
-- — создаём новый complex (берём атрибуты из любой quartiry этого проекта)
|
||||||
|
-- + добавляем complex_sources(source=objective).
|
||||||
|
|
||||||
|
WITH unmatched_objective AS (
|
||||||
|
SELECT DISTINCT ON (project_name)
|
||||||
|
project_name,
|
||||||
|
objective_project_id,
|
||||||
|
developer,
|
||||||
|
district,
|
||||||
|
class AS obj_class,
|
||||||
|
city,
|
||||||
|
address,
|
||||||
|
sales_start_date,
|
||||||
|
plan_completion_date,
|
||||||
|
actual_completion_date,
|
||||||
|
construction_stage
|
||||||
|
FROM objective_lots ol
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM complex_sources cs
|
||||||
|
WHERE cs.source = 'objective'
|
||||||
|
AND cs.source_id = ol.project_name
|
||||||
|
)
|
||||||
|
ORDER BY project_name, snapshot_date DESC NULLS LAST
|
||||||
|
),
|
||||||
|
inserted AS (
|
||||||
|
INSERT INTO complexes (
|
||||||
|
canonical_name, developer_name, region_id, district_name,
|
||||||
|
address, obj_class, sales_start_date, plan_completion_date,
|
||||||
|
actual_completion_date, site_status, primary_source
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
project_name,
|
||||||
|
developer,
|
||||||
|
66, -- Свердл.обл (Объектив пока только Екб + спутники)
|
||||||
|
district,
|
||||||
|
address,
|
||||||
|
obj_class,
|
||||||
|
sales_start_date,
|
||||||
|
plan_completion_date,
|
||||||
|
actual_completion_date,
|
||||||
|
construction_stage,
|
||||||
|
'objective'
|
||||||
|
FROM unmatched_objective
|
||||||
|
RETURNING id, canonical_name
|
||||||
|
)
|
||||||
|
INSERT INTO complex_sources (
|
||||||
|
complex_id, source, source_id, source_external_id,
|
||||||
|
match_method, match_score, is_reviewed, note
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
'objective',
|
||||||
|
uo.project_name,
|
||||||
|
uo.objective_project_id,
|
||||||
|
'imported',
|
||||||
|
1.0,
|
||||||
|
FALSE, -- нужно ревью: возможно есть domrf_kn-аналог
|
||||||
|
'orphan objective project; consider reviewing for domrf_kn match'
|
||||||
|
FROM unmatched_objective uo
|
||||||
|
JOIN inserted i ON i.canonical_name = uo.project_name
|
||||||
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 6. Helper-views для запросов ────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- v_complex_full: полная карточка ЖК с метриками из всех источников.
|
||||||
|
DROP VIEW IF EXISTS v_complex_full;
|
||||||
|
CREATE VIEW v_complex_full AS
|
||||||
|
SELECT
|
||||||
|
c.id AS complex_id,
|
||||||
|
c.canonical_name,
|
||||||
|
c.developer_name,
|
||||||
|
c.region_id,
|
||||||
|
c.district_name,
|
||||||
|
c.obj_class,
|
||||||
|
c.address,
|
||||||
|
c.latitude,
|
||||||
|
c.longitude,
|
||||||
|
c.flat_count,
|
||||||
|
c.square_living,
|
||||||
|
c.site_status,
|
||||||
|
-- сводка по источникам
|
||||||
|
(SELECT array_agg(source ORDER BY source) FROM complex_sources WHERE complex_id = c.id) AS sources,
|
||||||
|
(SELECT COUNT(*) FROM complex_sources WHERE complex_id = c.id) AS sources_count,
|
||||||
|
-- domrf_kn obj_id (если есть)
|
||||||
|
(SELECT source_external_id FROM complex_sources
|
||||||
|
WHERE complex_id = c.id AND source = 'domrf_kn' LIMIT 1) AS domrf_obj_id,
|
||||||
|
-- objective project_name (если есть)
|
||||||
|
(SELECT source_id FROM complex_sources
|
||||||
|
WHERE complex_id = c.id AND source = 'objective' LIMIT 1) AS objective_project_name,
|
||||||
|
c.primary_source,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM complexes c;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_complex_full IS
|
||||||
|
'Карточка ЖК с агрегированной информацией о всех источниках. '
|
||||||
|
'Используй для UI / отчётов / вместо direct lookup в domrf_kn_objects.';
|
||||||
|
|
||||||
|
-- v_complex_with_objective: ЖК + агрегаты Objective (lots count, sold pct, avg price)
|
||||||
|
DROP VIEW IF EXISTS v_complex_with_objective;
|
||||||
|
CREATE VIEW v_complex_with_objective AS
|
||||||
|
SELECT
|
||||||
|
c.id AS complex_id,
|
||||||
|
c.canonical_name,
|
||||||
|
c.district_name,
|
||||||
|
c.obj_class,
|
||||||
|
cs.source_id AS objective_project_name,
|
||||||
|
COUNT(ol.id) AS objective_lots_n,
|
||||||
|
COUNT(ol.id) FILTER (WHERE ol.is_sold) AS objective_sold_n,
|
||||||
|
ROUND(100.0 * COUNT(ol.id) FILTER (WHERE ol.is_sold)
|
||||||
|
/ NULLIF(COUNT(ol.id), 0), 1) AS objective_sold_pct,
|
||||||
|
AVG(ol.price_per_m2_rub) FILTER (WHERE ol.is_sold AND ol.price_per_m2_rub > 0) AS avg_sold_price_per_m2,
|
||||||
|
AVG(ol.area_pd) FILTER (WHERE ol.area_pd > 0) AS avg_area_pd,
|
||||||
|
MIN(ol.registration_date) AS first_reg_date,
|
||||||
|
MAX(ol.registration_date) AS last_reg_date
|
||||||
|
FROM complexes c
|
||||||
|
JOIN complex_sources cs
|
||||||
|
ON cs.complex_id = c.id AND cs.source = 'objective'
|
||||||
|
JOIN objective_lots ol
|
||||||
|
ON ol.project_name = cs.source_id
|
||||||
|
GROUP BY c.id, c.canonical_name, c.district_name, c.obj_class, cs.source_id;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_complex_with_objective IS
|
||||||
|
'ЖК + агрегаты Objective per-flat: sold count/pct, средняя цена, '
|
||||||
|
'диапазон reg_date. NULL во всех колонках = ЖК без Objective-источника.';
|
||||||
256
data/sql/74_dedupe_unified_views.sql
Normal file
256
data/sql/74_dedupe_unified_views.sql
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
-- Phase B + D: унификация дублирующихся таблиц через VIEW (UNION ALL).
|
||||||
|
--
|
||||||
|
-- Стратегия: НЕ дропаем источники (они продолжают писать своими скраперами/
|
||||||
|
-- импортёрами), но создаём поверх единые view для удобных запросов из
|
||||||
|
-- аналитики и UI. Это zero-risk миграция: можно делать в любой момент
|
||||||
|
-- и откатывать DROP VIEW без потери данных.
|
||||||
|
--
|
||||||
|
-- После того как код в backend перейдёт на use views — старые имена таблиц
|
||||||
|
-- можно будет либо превратить в views поверх objединённой структуры, либо
|
||||||
|
-- мигрировать данные целиком. Сейчас — promotion path.
|
||||||
|
--
|
||||||
|
-- Применено в проде через mcp postgres 2026-05-10.
|
||||||
|
-- Идемпотентно через CREATE OR REPLACE VIEW.
|
||||||
|
|
||||||
|
-- ── 1. ekb_districts: merge geom-таблицы в основную ─────────────────────────
|
||||||
|
-- ekb_districts (9, dimension с метриками) + ekb_districts_geom (8, polygons)
|
||||||
|
-- → одна таблица ekb_districts с PostGIS-колонкой geom
|
||||||
|
-- + view-alias ekb_districts_geom для backward-compat.
|
||||||
|
|
||||||
|
ALTER TABLE ekb_districts
|
||||||
|
ADD COLUMN IF NOT EXISTS geom geometry(MultiPolygon, 4326),
|
||||||
|
ADD COLUMN IF NOT EXISTS osm_id BIGINT,
|
||||||
|
ADD COLUMN IF NOT EXISTS geom_fetched_at timestamptz;
|
||||||
|
|
||||||
|
UPDATE ekb_districts d
|
||||||
|
SET geom = g.geom, osm_id = g.osm_id, geom_fetched_at = g.fetched_at
|
||||||
|
FROM ekb_districts_geom g
|
||||||
|
WHERE g.district_name = d.district_name;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ekb_districts_geom_idx
|
||||||
|
ON ekb_districts USING GIST(geom) WHERE geom IS NOT NULL;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS ekb_districts_geom;
|
||||||
|
CREATE OR REPLACE VIEW ekb_districts_geom AS
|
||||||
|
SELECT district_name, geom, osm_id, geom_fetched_at AS fetched_at
|
||||||
|
FROM ekb_districts WHERE geom IS NOT NULL;
|
||||||
|
|
||||||
|
-- ── 2. v_mortgage_unified — 4 источника ипотечных метрик в long-формате ─────
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_mortgage_unified AS
|
||||||
|
SELECT
|
||||||
|
'cbr' AS source,
|
||||||
|
NULL::date AS snapshot_date,
|
||||||
|
period AS period_label,
|
||||||
|
region,
|
||||||
|
NULL::text AS bank_name,
|
||||||
|
title AS metric_kind,
|
||||||
|
'total' AS segment,
|
||||||
|
value,
|
||||||
|
NULL::numeric AS delta_pct,
|
||||||
|
series_id AS metric_id
|
||||||
|
FROM cbr_mortgage_series
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'avg_rate_pct', 'total', total_credit_avg_rate, total_credit_avg_rate_delta, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'avg_rate_pct', 'primary', primary_credit_avg_rate, primary_credit_avg_rate_delta, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'avg_rate_pct', 'secondary', secondary_credit_avg_rate, secondary_credit_avg_rate_delta, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_count', 'total', total_credit_count::numeric, total_credit_count_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_count', 'primary', primary_credit_count::numeric, primary_credit_count_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_count', 'secondary', secondary_credit_count::numeric, secondary_credit_count_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_amount_rub', 'total', total_credit_amount, total_credit_amount_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_amount_rub', 'primary', primary_credit_amount, primary_credit_amount_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_dashboard', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_amount_rub', 'secondary', secondary_credit_amount, secondary_credit_amount_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_dashboard
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_details', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_amount_avg_rub', currency, credit_amount_avg, credit_amount_avg_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_details
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_details', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_avg_period_months', currency, credit_avg_period, credit_avg_period_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_details
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_details', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'credit_debts_amount_rub', currency, credit_debts_amount, credit_debts_amount_delta_pct, NULL
|
||||||
|
FROM domrf_mortgage_details
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_details', snapshot_date, NULL, 'РФ', NULL,
|
||||||
|
'overdue_pct', currency, credit_debts_overdue_percent, credit_debts_overdue_percent_delta, NULL
|
||||||
|
FROM domrf_mortgage_details
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_rates', snapshot_date, NULL, 'РФ', bank_name,
|
||||||
|
'rate_pct', 'primary', primary_rate, NULL, NULL FROM domrf_mortgage_rates
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_rates', snapshot_date, NULL, 'РФ', bank_name,
|
||||||
|
'rate_pct', 'secondary', secondary_rate, NULL, NULL FROM domrf_mortgage_rates
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'domrf_rates', snapshot_date, NULL, 'РФ', bank_name,
|
||||||
|
'rate_pct', 'refinance', refinance_rate, NULL, NULL FROM domrf_mortgage_rates;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_mortgage_unified IS
|
||||||
|
'Long-формат всех 4 источников ипотечных метрик. metric_kind: avg_rate_pct '
|
||||||
|
'/ credit_count / credit_amount_rub / overdue_pct / etc. segment: primary / '
|
||||||
|
'secondary / total. ~3385 rows total. Источники не дропаются.';
|
||||||
|
|
||||||
|
-- ── 3. v_construction_metrics_unified — 5 launch/commissioning ──────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_construction_metrics_unified AS
|
||||||
|
SELECT 'planned_commissioning' AS source, snapshot_date, plan_year::text AS period_label,
|
||||||
|
'РФ' AS region, 'planned_total_th_sqm' AS metric_kind,
|
||||||
|
NULL::text AS breakdown_kind, NULL::text AS breakdown_value,
|
||||||
|
total_th_sqm AS value,
|
||||||
|
jsonb_build_object('escrow', escrow_th_sqm, 'fund', fund_th_sqm,
|
||||||
|
'no_attraction', no_attraction_th_sqm) AS extra
|
||||||
|
FROM domrf_planned_commissioning
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'commissioning', snapshot_date, rep_year::text, region_name,
|
||||||
|
'fact_area_total', NULL, NULL, accumulated_fact_area,
|
||||||
|
jsonb_build_object('multifamily', accumulated_fact_area_multifamily,
|
||||||
|
'private', accumulated_fact_area_private,
|
||||||
|
'change', accumulated_fact_area_change,
|
||||||
|
'change_share', accumulated_fact_area_change_share)
|
||||||
|
FROM domrf_commissioning
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'launch_monthly', snapshot_date,
|
||||||
|
(rep_year::text || '-' || LPAD(rep_month::text, 2, '0')),
|
||||||
|
'РФ', 'launch_app_value', 'calc_type', calc_type, app_value,
|
||||||
|
jsonb_build_object('rnv_value', rnv_value)
|
||||||
|
FROM domrf_launch_monthly
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'launch_monthly', snapshot_date,
|
||||||
|
(rep_year::text || '-' || LPAD(rep_month::text, 2, '0')),
|
||||||
|
'РФ', 'launch_rnv_value', 'calc_type', calc_type, rnv_value, NULL
|
||||||
|
FROM domrf_launch_monthly
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'launch_top', snapshot_date, rep_year::text, entity_name,
|
||||||
|
metric_type, dim_type, entity_id, value,
|
||||||
|
jsonb_build_object('calc_type', calc_type)
|
||||||
|
FROM domrf_launch_top
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'launch_obj_class', snapshot_date, rep_year::text, 'РФ',
|
||||||
|
'launch_app_value', 'obj_class', obj_class_desc, app_value,
|
||||||
|
jsonb_build_object('rnv_value', rnv_value, 'calc_type', calc_type,
|
||||||
|
'obj_class_cd', obj_class_cd)
|
||||||
|
FROM domrf_launch_obj_class
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'launch_obj_class', snapshot_date, rep_year::text, 'РФ',
|
||||||
|
'launch_rnv_value', 'obj_class', obj_class_desc, rnv_value,
|
||||||
|
jsonb_build_object('calc_type', calc_type)
|
||||||
|
FROM domrf_launch_obj_class;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_construction_metrics_unified IS
|
||||||
|
'Long-формат для 5 таблиц про launch/commissioning. metric_kind: '
|
||||||
|
'planned_total_th_sqm / fact_area_total / launch_app_value / launch_rnv_value. '
|
||||||
|
'breakdown_kind: calc_type / obj_class / dim_type. ~1579 rows.';
|
||||||
|
|
||||||
|
-- ── 4. v_sold_ready_unified — 4 sold_ready / sold_out ──────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_sold_ready_unified AS
|
||||||
|
SELECT 'sold_out' AS source, snapshot_date, NULL::text AS period_label,
|
||||||
|
territory_name AS scope, NULL::text AS breakdown_kind, NULL::text AS breakdown_value,
|
||||||
|
'sold_pct' AS metric_kind, sold_pct AS value,
|
||||||
|
jsonb_build_object('total_area_th_sqm', total_area_th_sqm,
|
||||||
|
'sold_area_th_sqm', sold_area_th_sqm,
|
||||||
|
'unsold_pct', unsold_pct,
|
||||||
|
'avg_price_per_sqm', avg_price_per_sqm,
|
||||||
|
'attracted_funds_mln_rub', attracted_funds_mln_rub) AS extra
|
||||||
|
FROM domrf_sold_out
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'sold_ready_index', snapshot_date,
|
||||||
|
(rep_year::text || '-' || LPAD(rep_month::text, 2, '0')),
|
||||||
|
'РФ', NULL, NULL, 'sold_ready_perc', sold_ready_perc,
|
||||||
|
jsonb_build_object('square_sum', square_sum, 'sold_sum', sold_sum,
|
||||||
|
'sold_perc', sold_perc, 'ready_perc', ready_perc)
|
||||||
|
FROM domrf_sold_ready_index
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'sold_ready_dynamics', snapshot_date,
|
||||||
|
(rep_year::text || '-' || LPAD(rep_month::text, 2, '0')),
|
||||||
|
'РФ', 'dynamic_chart_type', dynamic_chart_type,
|
||||||
|
dynamic_chart_type, value, NULL
|
||||||
|
FROM domrf_sold_ready_dynamics
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'sold_ready_breakdown', snapshot_date,
|
||||||
|
(rep_year::text || '-' || LPAD(COALESCE(rep_month, 0)::text, 2, '0')),
|
||||||
|
COALESCE(NULLIF(dev_group_value, ''), 'РФ'),
|
||||||
|
chart_type, entity_key, 'sold_perc', sold_perc,
|
||||||
|
jsonb_build_object('ready_perc', ready_perc, 'sold_ready_perc', sold_ready_perc,
|
||||||
|
'square_sum', square_sum, 'fo_cd', fo_cd, 'region_cd', region_cd,
|
||||||
|
'obj_class', obj_class, 'ready_year', ready_year,
|
||||||
|
'city_population', city_population)
|
||||||
|
FROM domrf_sold_ready_breakdown;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_sold_ready_unified IS
|
||||||
|
'Long-формат 4 sold_ready таблиц. metric_kind: sold_pct / sold_ready_perc / '
|
||||||
|
'<dynamic_chart_type>. Big source: sold_ready_breakdown (~39k) — фильтруй '
|
||||||
|
'по chart_type/entity_key. Total: ~42858 rows.';
|
||||||
|
|
||||||
|
-- ── 5. v_scrape_runs_unified — 3 скрапера в одном журнале ───────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_scrape_runs_unified AS
|
||||||
|
SELECT 'kn' AS scraper_type, run_id, started_at, finished_at, heartbeat_at,
|
||||||
|
status, error, NULL::text AS triggered_by,
|
||||||
|
region_codes::text AS scope, requests_count,
|
||||||
|
objects_count AS items_ok, NULL::integer AS items_failed,
|
||||||
|
flats_count AS sub_items_ok,
|
||||||
|
progress_obj_index AS progress_idx, total_obj_count AS progress_total,
|
||||||
|
jsonb_build_object('developers', developer_ids, 'snapshot_date', snapshot_date,
|
||||||
|
'resumed_from', resumed_from_run_id, 'params', params) AS extra
|
||||||
|
FROM kn_scrape_runs
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'nspd', run_id, started_at, finished_at, heartbeat_at, status, error,
|
||||||
|
triggered_by, region_code::text, requests_count,
|
||||||
|
quarters_ok, quarters_failed, buildings_ok,
|
||||||
|
NULL, pending_count,
|
||||||
|
jsonb_build_object('waf_429_count', waf_429_count)
|
||||||
|
FROM nspd_scrape_runs
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'objective', run_id, started_at, finished_at, heartbeat_at, status, error,
|
||||||
|
triggered_by, group_name, requests_count,
|
||||||
|
reports_ok, reports_failed, rows_lots,
|
||||||
|
NULL, NULL,
|
||||||
|
jsonb_build_object('rows_corpus_room', rows_corpus_room, 'rows_history', rows_history)
|
||||||
|
FROM objective_scrape_runs;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_scrape_runs_unified IS
|
||||||
|
'Унифицированный журнал прогонов для всех 3 скраперов. Полиморфные общие '
|
||||||
|
'поля (items_ok / sub_items_ok / scope) — для UI; специфичные — в extra '
|
||||||
|
'jsonb. Используй для общего admin /scrape dashboard вместо 3 отдельных endpoint-ов.';
|
||||||
|
|
||||||
|
-- ── 6. v_scrape_log_unified — kn + nspd логи ────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_scrape_log_unified AS
|
||||||
|
SELECT 'kn' AS scraper_type, log_id, run_id, ts, level, stage,
|
||||||
|
obj_id::text AS entity_id, message
|
||||||
|
FROM kn_scrape_log
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'nspd', log_id, run_id, ts, level, stage, cad_number, message
|
||||||
|
FROM nspd_scrape_log;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_scrape_log_unified IS
|
||||||
|
'Per-step log двух скраперов. entity_id = obj_id для kn / cad_number для nspd. '
|
||||||
|
'objective scraper пока не пишет log — добавим если понадобится debug.';
|
||||||
123
data/sql/75_unification_naming.sql
Normal file
123
data/sql/75_unification_naming.sql
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
-- Унификация naming: region_cd/region_code → region_id, dev_id → developer_id,
|
||||||
|
-- dev_name → developer_name.
|
||||||
|
--
|
||||||
|
-- ПОДХОД: GENERATED ALWAYS AS (...) STORED — добавляем синонимные колонки
|
||||||
|
-- которые автоматически синхронизируются с источником. НЕ ALTER RENAME COLUMN
|
||||||
|
-- (последний сразу сломает работающий backend, который ссылается на старые
|
||||||
|
-- имена в SQL text() literals в 10 файлах).
|
||||||
|
--
|
||||||
|
-- Backend продолжает работать со старыми именами. Новый код может
|
||||||
|
-- использовать унифицированные. Постепенно мигрировать backend → потом DROP
|
||||||
|
-- старых колонок отдельной миграцией.
|
||||||
|
--
|
||||||
|
-- Storage cost: int 4B × N rows. Для rosreestr_deals (~7M rows) = ~28 MB.
|
||||||
|
-- Суммарно по всем таблицам ~50 MB. Приемлемо.
|
||||||
|
--
|
||||||
|
-- Применено в проде через mcp postgres 2026-05-10.
|
||||||
|
-- Идемпотентно через ADD COLUMN IF NOT EXISTS.
|
||||||
|
|
||||||
|
-- ── Plus task 2/3 (выполнены отдельным запросом, документирую здесь) ────────
|
||||||
|
-- UPDATE complexes SET obj_class и district_name из objective_lots для 350
|
||||||
|
-- сматченных через cs.source='objective'. obj_class заполнен = 350,
|
||||||
|
-- district_name = 1525 (приоритет — Objective).
|
||||||
|
|
||||||
|
-- ── region_cd → region_id (5 таблиц) ────────────────────────────────────────
|
||||||
|
ALTER TABLE domrf_kn_objects ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_cd) STORED;
|
||||||
|
ALTER TABLE domrf_kn_flats ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_cd) STORED;
|
||||||
|
ALTER TABLE domrf_project_finance ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_cd) STORED;
|
||||||
|
ALTER TABLE domrf_sold_ready_breakdown ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_cd) STORED;
|
||||||
|
ALTER TABLE rosreestr_rent_deals ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_cd) STORED;
|
||||||
|
|
||||||
|
-- ── region_code → region_id (3 таблицы; rosreestr_deals = partitioned, → 9 партиций) ──
|
||||||
|
ALTER TABLE domrf_realization ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_code) STORED;
|
||||||
|
ALTER TABLE nspd_scrape_runs ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_code) STORED;
|
||||||
|
ALTER TABLE rosreestr_deals ADD COLUMN IF NOT EXISTS region_id INT GENERATED ALWAYS AS (region_code) STORED;
|
||||||
|
|
||||||
|
-- ── dev_id → developer_id (3 таблицы) ───────────────────────────────────────
|
||||||
|
ALTER TABLE domrf_kn_objects ADD COLUMN IF NOT EXISTS developer_id TEXT GENERATED ALWAYS AS (dev_id) STORED;
|
||||||
|
ALTER TABLE domrf_dev_growth ADD COLUMN IF NOT EXISTS developer_id TEXT GENERATED ALWAYS AS (dev_id) STORED;
|
||||||
|
ALTER TABLE sv_dev_sales ADD COLUMN IF NOT EXISTS developer_id TEXT GENERATED ALWAYS AS (dev_id) STORED;
|
||||||
|
|
||||||
|
-- ── dev_name → developer_name (2 таблицы) ───────────────────────────────────
|
||||||
|
ALTER TABLE domrf_kn_objects ADD COLUMN IF NOT EXISTS developer_name TEXT GENERATED ALWAYS AS (dev_name) STORED;
|
||||||
|
ALTER TABLE domrf_dev_growth ADD COLUMN IF NOT EXISTS developer_name TEXT GENERATED ALWAYS AS (dev_name) STORED;
|
||||||
|
|
||||||
|
-- ── COMMENT-указатель миграции ──────────────────────────────────────────────
|
||||||
|
COMMENT ON COLUMN domrf_kn_objects.region_cd IS
|
||||||
|
'DEPRECATED: используй region_id (синоним через GENERATED). '
|
||||||
|
'Удалить после миграции backend SQL queries.';
|
||||||
|
COMMENT ON COLUMN domrf_kn_objects.dev_id IS
|
||||||
|
'DEPRECATED: используй developer_id (синоним через GENERATED).';
|
||||||
|
COMMENT ON COLUMN domrf_kn_objects.dev_name IS
|
||||||
|
'DEPRECATED: используй developer_name (синоним через GENERATED).';
|
||||||
|
COMMENT ON COLUMN rosreestr_deals.region_code IS
|
||||||
|
'DEPRECATED: используй region_id (синоним через GENERATED).';
|
||||||
|
|
||||||
|
-- ── 4. complexes update: obj_class + district из Objective ──────────────────
|
||||||
|
WITH objective_per_complex AS (
|
||||||
|
SELECT DISTINCT ON (cs.complex_id) cs.complex_id,
|
||||||
|
ol.class AS obj_class,
|
||||||
|
ol.district AS district_name
|
||||||
|
FROM complex_sources cs
|
||||||
|
JOIN objective_lots ol ON ol.project_name = cs.source_id
|
||||||
|
WHERE cs.source = 'objective'
|
||||||
|
ORDER BY cs.complex_id, ol.snapshot_date DESC NULLS LAST
|
||||||
|
)
|
||||||
|
UPDATE complexes c
|
||||||
|
SET obj_class = COALESCE(opc.obj_class, c.obj_class),
|
||||||
|
district_name = COALESCE(opc.district_name, c.district_name),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM objective_per_complex opc
|
||||||
|
WHERE opc.complex_id = c.id;
|
||||||
|
|
||||||
|
-- ── 5. Fuzzy auto-merge orphan complexes (high-confidence ≥0.85) ────────────
|
||||||
|
-- См. полный SQL-блок в applied миграции через mcp postgres.
|
||||||
|
-- Эффект: 14 high-confidence orphan Objective complexes слились с domrf_kn
|
||||||
|
-- (например 'Чемпион Парк' / 'Discovery Residence' / 'АЛЛЕГРО'). После merge:
|
||||||
|
-- complexes 1752 → 1740, in_both_sources_now 114 → 124.
|
||||||
|
|
||||||
|
-- ── 6. v_complex_match_review для остальных 82 кандидатов ───────────────────
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_complex_match_review AS
|
||||||
|
WITH orphan_complexes AS (
|
||||||
|
SELECT c.id AS orphan_id, c.canonical_name AS orphan_name,
|
||||||
|
c.developer_name AS orphan_dev, c.district_name AS orphan_district,
|
||||||
|
c.obj_class AS orphan_class
|
||||||
|
FROM complexes c WHERE c.primary_source = 'objective'
|
||||||
|
),
|
||||||
|
kn_complexes AS (
|
||||||
|
SELECT c.id AS kn_id, c.canonical_name AS kn_name,
|
||||||
|
c.developer_name AS kn_dev, c.district_name AS kn_district
|
||||||
|
FROM complexes c WHERE c.primary_source = 'domrf_kn'
|
||||||
|
),
|
||||||
|
candidates AS (
|
||||||
|
SELECT oc.*, kc.kn_id, kc.kn_name, kc.kn_dev, kc.kn_district,
|
||||||
|
similarity(oc.orphan_name, kc.kn_name) AS name_sim,
|
||||||
|
CASE WHEN oc.orphan_dev IS NOT NULL AND kc.kn_dev IS NOT NULL
|
||||||
|
THEN similarity(oc.orphan_dev, kc.kn_dev) ELSE 0 END AS dev_sim,
|
||||||
|
CASE WHEN oc.orphan_district = kc.kn_district THEN 1.0 ELSE 0 END AS district_match
|
||||||
|
FROM orphan_complexes oc CROSS JOIN kn_complexes kc
|
||||||
|
WHERE similarity(oc.orphan_name, kc.kn_name) >= 0.4
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *, (name_sim * 0.7 + dev_sim * 0.2 + district_match * 0.1) AS combined_score,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY orphan_id ORDER BY (name_sim * 0.7 + dev_sim * 0.2 + district_match * 0.1) DESC) AS rk
|
||||||
|
FROM candidates
|
||||||
|
)
|
||||||
|
SELECT orphan_id, orphan_name, orphan_dev, orphan_class, orphan_district,
|
||||||
|
kn_id, kn_name, kn_dev, kn_district,
|
||||||
|
ROUND(name_sim::numeric, 3) AS name_sim,
|
||||||
|
ROUND(dev_sim::numeric, 3) AS dev_sim,
|
||||||
|
ROUND(combined_score::numeric, 3) AS score,
|
||||||
|
CASE WHEN combined_score >= 0.7 THEN 'likely'
|
||||||
|
WHEN combined_score >= 0.5 THEN 'maybe'
|
||||||
|
ELSE 'unlikely' END AS confidence
|
||||||
|
FROM ranked WHERE rk = 1 AND combined_score >= 0.5
|
||||||
|
ORDER BY combined_score DESC;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_complex_match_review IS
|
||||||
|
'Кандидаты на merge orphan Objective complexes с domrf_kn. Top-1 candidate '
|
||||||
|
'per orphan_id с similarity >= 0.5. Для review через admin UI: '
|
||||||
|
'если confidence=likely (≥0.7) — почти наверняка дубль, maybe (≥0.5) — '
|
||||||
|
'посмотреть глазами. Auto-merge для confidence>=0.85 уже выполнен.';
|
||||||
|
|
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
{ href: "/admin/scrape/all", label: "📊 Все скраперы (сводно)" },
|
||||||
{ href: "/admin/scrape", label: "Скрапер DOM.РФ" },
|
{ href: "/admin/scrape", label: "Скрапер DOM.РФ" },
|
||||||
{ href: "/admin/scrape/nspd", label: "Скрапер NSPD" },
|
{ href: "/admin/scrape/nspd", label: "Скрапер NSPD" },
|
||||||
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
||||||
|
|
|
||||||
538
frontend/src/app/admin/scrape/all/page.tsx
Normal file
538
frontend/src/app/admin/scrape/all/page.tsx
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
interface UnifiedRunRow {
|
||||||
|
scraper_type: "kn" | "nspd" | "objective";
|
||||||
|
run_id: number;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
heartbeat_at: string | null;
|
||||||
|
status: string;
|
||||||
|
error: string | null;
|
||||||
|
triggered_by: string | null;
|
||||||
|
scope: string | null;
|
||||||
|
requests_count: number | null;
|
||||||
|
items_ok: number | null;
|
||||||
|
items_failed: number | null;
|
||||||
|
sub_items_ok: number | null;
|
||||||
|
progress_idx: number | null;
|
||||||
|
progress_total: number | null;
|
||||||
|
extra: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedLogRow {
|
||||||
|
scraper_type: "kn" | "nspd";
|
||||||
|
log_id: number;
|
||||||
|
run_id: number | null;
|
||||||
|
ts: string | null;
|
||||||
|
level: "debug" | "info" | "warn" | "error";
|
||||||
|
stage: string | null;
|
||||||
|
entity_id: string | null;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRAPER_BADGE: Record<string, { bg: string; fg: string; label: string }> =
|
||||||
|
{
|
||||||
|
kn: { bg: "#dbeafe", fg: "#1d4ed8", label: "DOM.РФ kn" },
|
||||||
|
nspd: { bg: "#fef3c7", fg: "#a16207", label: "NSPD" },
|
||||||
|
objective: { bg: "#d1fae5", fg: "#059669", label: "Objective" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
done: "#16a34a",
|
||||||
|
running: "#1d4ed8",
|
||||||
|
failed: "#b3261e",
|
||||||
|
zombie: "#9333ea",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatIso(s: string | null): string {
|
||||||
|
if (!s) return "—";
|
||||||
|
return new Date(s).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffSecs(a: string | null, b: string | null): string {
|
||||||
|
if (!a || !b) return "—";
|
||||||
|
const dt = (new Date(b).getTime() - new Date(a).getTime()) / 1000;
|
||||||
|
if (dt < 60) return `${dt.toFixed(0)}с`;
|
||||||
|
if (dt < 3600) return `${(dt / 60).toFixed(1)} мин`;
|
||||||
|
return `${(dt / 3600).toFixed(1)} ч`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScrapeAllAdminPage() {
|
||||||
|
const [token, setToken] = useState<string>(() =>
|
||||||
|
typeof window === "undefined"
|
||||||
|
? ""
|
||||||
|
: (localStorage.getItem("admin_token") ?? ""),
|
||||||
|
);
|
||||||
|
const [filter, setFilter] = useState<"all" | "kn" | "nspd" | "objective">(
|
||||||
|
"all",
|
||||||
|
);
|
||||||
|
const [logsRunId, setLogsRunId] = useState<number | "">("");
|
||||||
|
|
||||||
|
const saveToken = (v: string) => {
|
||||||
|
setToken(v);
|
||||||
|
localStorage.setItem("admin_token", v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runs = useQuery({
|
||||||
|
queryKey: ["unified-runs", token, filter],
|
||||||
|
queryFn: () => {
|
||||||
|
const q =
|
||||||
|
filter !== "all" ? `?scraper_type=${filter}&limit=30` : "?limit=30";
|
||||||
|
return apiFetch<UnifiedRunRow[]>(`/api/v1/admin/scrape/all/runs${q}`, {
|
||||||
|
headers: { "X-Admin-Token": token },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: !!token,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = useQuery({
|
||||||
|
queryKey: ["unified-logs", token, filter, logsRunId],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams({ limit: "100" });
|
||||||
|
if (filter !== "all") params.set("scraper_type", filter);
|
||||||
|
if (logsRunId !== "") params.set("run_id", String(logsRunId));
|
||||||
|
return apiFetch<UnifiedLogRow[]>(
|
||||||
|
`/api/v1/admin/scrape/all/logs?${params}`,
|
||||||
|
{ headers: { "X-Admin-Token": token } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!token,
|
||||||
|
refetchInterval: 8000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сводка по statusам
|
||||||
|
const summary = (() => {
|
||||||
|
const s = { running: 0, done: 0, failed: 0, zombie: 0 } as Record<
|
||||||
|
string,
|
||||||
|
number
|
||||||
|
>;
|
||||||
|
runs.data?.forEach((r) => {
|
||||||
|
s[r.status] = (s[r.status] ?? 0) + 1;
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: 18 }}>
|
||||||
|
Скраперы — сводный dashboard
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
|
||||||
|
Все 3 скрапера в одной таблице (через <code>v_scrape_runs_unified</code>
|
||||||
|
). Для запуска / триггеров — отдельные страницы:{" "}
|
||||||
|
<a href="/admin/scrape" style={{ color: "#1d4ed8" }}>
|
||||||
|
kn
|
||||||
|
</a>{" "}
|
||||||
|
·{" "}
|
||||||
|
<a href="/admin/scrape/nspd" style={{ color: "#1d4ed8" }}>
|
||||||
|
NSPD
|
||||||
|
</a>{" "}
|
||||||
|
·{" "}
|
||||||
|
<a href="/admin/scrape/objective" style={{ color: "#1d4ed8" }}>
|
||||||
|
Objective
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section style={cardStyle}>
|
||||||
|
<label style={{ display: "block" }}>
|
||||||
|
<span style={labelStyle}>Admin Token</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => saveToken(e.target.value)}
|
||||||
|
placeholder="SCRAPE_ADMIN_TOKEN"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Сводка-плитки */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
...cardStyle,
|
||||||
|
marginTop: 16,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stat
|
||||||
|
label="🟦 в работе"
|
||||||
|
value={summary.running ?? 0}
|
||||||
|
color="#1d4ed8"
|
||||||
|
/>
|
||||||
|
<Stat label="✅ успешно" value={summary.done ?? 0} color="#16a34a" />
|
||||||
|
<Stat label="❌ failed" value={summary.failed ?? 0} color="#b3261e" />
|
||||||
|
<Stat label="🧟 zombie" value={summary.zombie ?? 0} color="#9333ea" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Фильтр + табы */}
|
||||||
|
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<span style={{ ...labelStyle, marginBottom: 0 }}>Filter:</span>
|
||||||
|
{(["all", "kn", "nspd", "objective"] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(t)}
|
||||||
|
style={{
|
||||||
|
...filterTab,
|
||||||
|
background: filter === t ? "#1d4ed8" : "#f3f4f6",
|
||||||
|
color: filter === t ? "#fff" : "#1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t === "all" ? "Все" : (SCRAPER_BADGE[t]?.label ?? t)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Таблица прогонов */}
|
||||||
|
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||||
|
<h3 style={sectionTitle}>
|
||||||
|
📜 Последние прогоны (top 30, обновляется каждые 5с)
|
||||||
|
</h3>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead style={{ background: "#f9fafb" }}>
|
||||||
|
<tr>
|
||||||
|
<th style={th}>scraper</th>
|
||||||
|
<th style={th}>run_id</th>
|
||||||
|
<th style={th}>started</th>
|
||||||
|
<th style={th}>scope</th>
|
||||||
|
<th style={th}>duration</th>
|
||||||
|
<th style={{ ...th, textAlign: "right" }}>items_ok</th>
|
||||||
|
<th style={{ ...th, textAlign: "right" }}>failed</th>
|
||||||
|
<th style={{ ...th, textAlign: "right" }}>sub_ok</th>
|
||||||
|
<th style={th}>status</th>
|
||||||
|
<th style={th}>trigger</th>
|
||||||
|
<th style={th}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.data?.map((r) => {
|
||||||
|
const heartbeatLag = r.heartbeat_at
|
||||||
|
? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000
|
||||||
|
: null;
|
||||||
|
const isStale =
|
||||||
|
r.status === "running" &&
|
||||||
|
heartbeatLag !== null &&
|
||||||
|
heartbeatLag > 60;
|
||||||
|
const badge = SCRAPER_BADGE[r.scraper_type];
|
||||||
|
const statusColor = STATUS_COLOR[r.status] ?? "#6b7280";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${r.scraper_type}-${r.run_id}`}
|
||||||
|
style={{ borderTop: "1px solid #f3f4f6" }}
|
||||||
|
>
|
||||||
|
<td style={td}>
|
||||||
|
{badge && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: badge.bg,
|
||||||
|
color: badge.fg,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, fontFamily: "monospace" }}>
|
||||||
|
{r.run_id}
|
||||||
|
</td>
|
||||||
|
<td style={td}>{formatIso(r.started_at)}</td>
|
||||||
|
<td
|
||||||
|
style={{ ...td, fontFamily: "monospace", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{r.scope ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
{diffSecs(r.started_at, r.finished_at ?? r.heartbeat_at)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...td,
|
||||||
|
textAlign: "right",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.items_ok?.toLocaleString("ru-RU") ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...td,
|
||||||
|
textAlign: "right",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: r.items_failed ? "#b3261e" : "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.items_failed?.toLocaleString("ru-RU") ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...td,
|
||||||
|
textAlign: "right",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.sub_items_ok?.toLocaleString("ru-RU") ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: statusColor + "22",
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isStale ? "stale" : r.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, color: "#6b7280" }}>
|
||||||
|
{r.triggered_by ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
{(r.scraper_type === "kn" ||
|
||||||
|
r.scraper_type === "nspd") && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLogsRunId(r.run_id)}
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
логи
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{runs.data?.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={11}
|
||||||
|
style={{
|
||||||
|
...td,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#6b7280",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Прогонов с фильтром <code>{filter}</code> нет
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||||
|
<h3 style={sectionTitle}>
|
||||||
|
📋 Логи (top 100){" "}
|
||||||
|
{logsRunId !== "" && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 400, color: "#5b6066" }}>
|
||||||
|
· фильтр run_id={logsRunId}{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLogsRunId("")}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#1d4ed8",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
сбросить
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div style={{ overflowX: "auto", maxHeight: 400, overflowY: "auto" }}>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead
|
||||||
|
style={{ background: "#f9fafb", position: "sticky", top: 0 }}
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th style={th}>ts</th>
|
||||||
|
<th style={th}>scraper</th>
|
||||||
|
<th style={th}>run</th>
|
||||||
|
<th style={th}>level</th>
|
||||||
|
<th style={th}>stage</th>
|
||||||
|
<th style={th}>entity</th>
|
||||||
|
<th style={th}>message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.data?.map((l) => (
|
||||||
|
<tr
|
||||||
|
key={`${l.scraper_type}-${l.log_id}`}
|
||||||
|
style={{ borderTop: "1px solid #f3f4f6" }}
|
||||||
|
>
|
||||||
|
<td style={{ ...td, fontSize: 11, whiteSpace: "nowrap" }}>
|
||||||
|
{formatIso(l.ts)}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: SCRAPER_BADGE[l.scraper_type]?.fg ?? "#6b7280",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.scraper_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, fontFamily: "monospace" }}>
|
||||||
|
{l.run_id ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
l.level === "error"
|
||||||
|
? "#b3261e"
|
||||||
|
: l.level === "warn"
|
||||||
|
? "#a16207"
|
||||||
|
: "#5b6066",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.level}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, fontSize: 11 }}>{l.stage ?? "—"}</td>
|
||||||
|
<td style={{ ...td, fontFamily: "monospace", fontSize: 11 }}>
|
||||||
|
{l.entity_id ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, fontSize: 12 }}>{l.message}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{logs.data?.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
style={{
|
||||||
|
...td,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#6b7280",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Логов нет (objective пока не пишет в log — только runs)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: "#5b6066", marginBottom: 4 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle = {
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6e8ec",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
};
|
||||||
|
const sectionTitle = {
|
||||||
|
margin: "0 0 12px",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600 as const,
|
||||||
|
};
|
||||||
|
const labelStyle = {
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#5b6066",
|
||||||
|
marginBottom: 4,
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
};
|
||||||
|
const inputStyle = {
|
||||||
|
padding: "8px 10px",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box" as const,
|
||||||
|
};
|
||||||
|
const filterTab = {
|
||||||
|
padding: "6px 14px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500 as const,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
const secondaryBtn = {
|
||||||
|
padding: "8px 14px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
color: "#1f2937",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
const tableStyle = {
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse" as const,
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
const th = {
|
||||||
|
padding: "8px 10px",
|
||||||
|
textAlign: "left" as const,
|
||||||
|
fontWeight: 600 as const,
|
||||||
|
borderBottom: "1px solid #e6e8ec",
|
||||||
|
};
|
||||||
|
const td = { padding: "8px 10px" };
|
||||||
Loading…
Add table
Reference in a new issue