Merge pull request 'fix(week-review): аудит код-ревью — 181 фиксов (label week ревью 1)' (#1543) from fix/week-review-audit into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 7s
Deploy / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 36s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy / build-backend (push) Successful in 1m55s
Deploy / build-worker (push) Successful in 2m59s
Deploy / build-frontend (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m21s
Some checks failed
Deploy Trade-In / changes (push) Successful in 7s
Deploy / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 36s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy / build-backend (push) Successful in 1m55s
Deploy / build-worker (push) Successful in 2m59s
Deploy / build-frontend (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m21s
Reviewed-on: #1543
This commit is contained in:
commit
8d1a13ee5e
118 changed files with 2438 additions and 757 deletions
|
|
@ -11,6 +11,7 @@ Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
|
@ -22,6 +23,8 @@ from sqlalchemy.orm import Session
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,7 +72,7 @@ def trigger_kn_sweep(
|
||||||
"task_id": result.id,
|
"task_id": result.id,
|
||||||
"region_code": payload.region_code,
|
"region_code": payload.region_code,
|
||||||
"developers": payload.developers,
|
"developers": payload.developers,
|
||||||
"queued_at": result.date_done.isoformat() if result.date_done else None,
|
"queued_at": "now",
|
||||||
"force": payload.force,
|
"force": payload.force,
|
||||||
"lock_was_released": lock_released,
|
"lock_was_released": lock_released,
|
||||||
}
|
}
|
||||||
|
|
@ -175,9 +178,16 @@ def queue_status(
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _safe(fn):
|
def _safe(fn):
|
||||||
|
# Деградация (return None при недоступном broker) намеренна для UI-poll
|
||||||
|
# эндпоинта, но логируем чтобы устойчиво сломанный broker был виден в логах.
|
||||||
try:
|
try:
|
||||||
return fn()
|
return fn()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"queue_status: celery inspect call %s failed",
|
||||||
|
getattr(fn, "__name__", fn),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
deadline = time.monotonic() + 0.8
|
deadline = time.monotonic() + 0.8
|
||||||
|
|
@ -203,6 +213,9 @@ def queue_status(
|
||||||
with conn.channel() as channel:
|
with conn.channel() as channel:
|
||||||
queue_depth = channel.client.llen("celery")
|
queue_depth = channel.client.llen("celery")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Намеренная деградация для UI-poll; логируем чтобы недоступный broker
|
||||||
|
# не был невидим в логах (см. .claude/rules/backend.md).
|
||||||
|
logger.warning("queue_status: broker queue_depth probe failed", exc_info=True)
|
||||||
queue_depth = None
|
queue_depth = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -769,6 +782,7 @@ class BulkGeoEnqueueRequest(BaseModel):
|
||||||
"all_in_region — UNION всех cad из rosreestr_deals + cad_buildings + complexes"
|
"all_in_region — UNION всех cad из rosreestr_deals + cad_buildings + complexes"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
rate_ms: int = Field(default=600, ge=100, le=10000)
|
||||||
|
|
||||||
|
|
||||||
# Маппинг thematic_id → таблица проверки существования + колонка + job_kind-метка
|
# Маппинг thematic_id → таблица проверки существования + колонка + job_kind-метка
|
||||||
|
|
@ -929,6 +943,7 @@ def bulk_enqueue_geo(
|
||||||
"source": payload.source,
|
"source": payload.source,
|
||||||
},
|
},
|
||||||
cad_nums_with_thematic=cad_with_thematic,
|
cad_nums_with_thematic=cad_with_thematic,
|
||||||
|
rate_ms=payload.rate_ms,
|
||||||
triggered_by="bulk_admin",
|
triggered_by="bulk_admin",
|
||||||
)
|
)
|
||||||
process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue)
|
process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.schemas.concept import ConceptInput, ConceptOutput
|
from app.schemas.concept import ConceptInput, ConceptOutput
|
||||||
from app.services.generative import geometry
|
from app.services.generative import geometry
|
||||||
|
|
@ -23,7 +24,9 @@ async def create_concept(payload: ConceptInput) -> ConceptOutput:
|
||||||
422 rather than empty variants — that is a bad request, not a valid empty result.
|
422 rather than empty variants — that is a bad request, not a valid empty result.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
variants = geometry.generate(payload)
|
# geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
|
||||||
|
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
|
||||||
|
variants = await run_in_threadpool(geometry.generate, payload)
|
||||||
except ParcelGeometryError as exc:
|
except ParcelGeometryError as exc:
|
||||||
logger.warning("concept generation rejected parcel: %s", exc)
|
logger.warning("concept generation rejected parcel: %s", exc)
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -1457,7 +1457,8 @@ def analyze_parcel(
|
||||||
) AS dist_to_center
|
) AS dist_to_center
|
||||||
FROM ekb_districts d
|
FROM ekb_districts d
|
||||||
LEFT JOIN mv_quarter_price_per_m2 mq
|
LEFT JOIN mv_quarter_price_per_m2 mq
|
||||||
ON mq.quarter_cad_number = REGEXP_REPLACE(:cad_num, ':[^:]+$', '')
|
ON mq.quarter_cad_number =
|
||||||
|
REGEXP_REPLACE(:cad_num, '^([^:]+:[^:]+:[^:]+).*$', '\\1')
|
||||||
WHERE ST_DWithin(
|
WHERE ST_DWithin(
|
||||||
d.geom::geography,
|
d.geom::geography,
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
|
@ -1841,9 +1842,10 @@ def analyze_parcel(
|
||||||
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
||||||
hydrology: dict[str, Any] | None = None
|
hydrology: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
hydro_rows = (
|
with db.begin_nested():
|
||||||
db.execute(
|
hydro_rows = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT source_type, road_class, name,
|
SELECT source_type, road_class, name,
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
n.geom::geography,
|
n.geom::geography,
|
||||||
|
|
@ -1859,11 +1861,11 @@ def analyze_parcel(
|
||||||
ORDER BY distance_m ASC
|
ORDER BY distance_m ASC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
"""),
|
"""),
|
||||||
{"wkt": geom_wkt},
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
hydrology = {
|
hydrology = {
|
||||||
"nearest": [
|
"nearest": [
|
||||||
{
|
{
|
||||||
|
|
@ -1890,9 +1892,10 @@ def analyze_parcel(
|
||||||
# 9d) Utilities — power lines + pipelines из OSM (магистральные сети)
|
# 9d) Utilities — power lines + pipelines из OSM (магистральные сети)
|
||||||
utilities: dict[str, Any] | None = None
|
utilities: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
util_rows = (
|
with db.begin_nested():
|
||||||
db.execute(
|
util_rows = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT road_class, name,
|
SELECT road_class, name,
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
n.geom::geography,
|
n.geom::geography,
|
||||||
|
|
@ -1908,11 +1911,11 @@ def analyze_parcel(
|
||||||
ORDER BY distance_m ASC
|
ORDER BY distance_m ASC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
"""),
|
"""),
|
||||||
{"wkt": geom_wkt},
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
# Группировка по типу для compactness. util_rows отсортированы по
|
# Группировка по типу для compactness. util_rows отсортированы по
|
||||||
# distance_m ASC → первый встреченный road_class = ближайший.
|
# distance_m ASC → первый встреченный road_class = ближайший.
|
||||||
by_subtype: dict[str, dict[str, Any]] = {}
|
by_subtype: dict[str, dict[str, Any]] = {}
|
||||||
|
|
@ -1974,9 +1977,10 @@ def analyze_parcel(
|
||||||
# 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
|
# 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
|
||||||
parcel_meta: ParcelMeta | None = None
|
parcel_meta: ParcelMeta | None = None
|
||||||
try:
|
try:
|
||||||
pm_row = (
|
with db.begin_nested():
|
||||||
db.execute(
|
pm_row = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT permitted_use_established_by_document AS permitted_use,
|
SELECT permitted_use_established_by_document AS permitted_use,
|
||||||
land_record_category_type AS land_category,
|
land_record_category_type AS land_category,
|
||||||
land_record_subtype AS land_subtype,
|
land_record_subtype AS land_subtype,
|
||||||
|
|
@ -1985,11 +1989,11 @@ def analyze_parcel(
|
||||||
WHERE cad_num = CAST(:c AS text)
|
WHERE cad_num = CAST(:c AS text)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""),
|
"""),
|
||||||
{"c": cad_num},
|
{"c": cad_num},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if pm_row:
|
if pm_row:
|
||||||
parcel_meta = ParcelMeta(
|
parcel_meta = ParcelMeta(
|
||||||
permitted_use=pm_row["permitted_use"],
|
permitted_use=pm_row["permitted_use"],
|
||||||
|
|
@ -2003,9 +2007,10 @@ def analyze_parcel(
|
||||||
# B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5)
|
# B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5)
|
||||||
egrn_block: dict[str, Any] = {}
|
egrn_block: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
egrn_row = (
|
with db.begin_nested():
|
||||||
db.execute(
|
egrn_row = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT cost_value AS cadastral_value_rub,
|
SELECT cost_value AS cadastral_value_rub,
|
||||||
cost_index AS cost_index_per_m2,
|
cost_index AS cost_index_per_m2,
|
||||||
land_record_category_type AS land_category,
|
land_record_category_type AS land_category,
|
||||||
|
|
@ -2021,11 +2026,11 @@ def analyze_parcel(
|
||||||
WHERE cad_num = CAST(:c AS text)
|
WHERE cad_num = CAST(:c AS text)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""),
|
"""),
|
||||||
{"c": cad_num},
|
{"c": cad_num},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if egrn_row:
|
if egrn_row:
|
||||||
_cad_val = (
|
_cad_val = (
|
||||||
float(egrn_row["cadastral_value_rub"])
|
float(egrn_row["cadastral_value_rub"])
|
||||||
|
|
@ -2070,19 +2075,20 @@ def analyze_parcel(
|
||||||
"zouit_count": 0,
|
"zouit_count": 0,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
zouit_rows = (
|
with db.begin_nested():
|
||||||
db.execute(
|
zouit_rows = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT type_zone, name_by_doc
|
SELECT type_zone, name_by_doc
|
||||||
FROM cad_zouit
|
FROM cad_zouit
|
||||||
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
"""),
|
"""),
|
||||||
{"wkt": geom_wkt},
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if zouit_rows:
|
if zouit_rows:
|
||||||
_zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]})
|
_zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]})
|
||||||
encumbrance_block = {
|
encumbrance_block = {
|
||||||
|
|
@ -2096,9 +2102,10 @@ def analyze_parcel(
|
||||||
# B5-3) Red lines block — пересечение с cad_red_lines (SF-B5)
|
# B5-3) Red lines block — пересечение с cad_red_lines (SF-B5)
|
||||||
red_lines_block: dict[str, Any] = {"intersects": False, "count": 0}
|
red_lines_block: dict[str, Any] = {"intersects": False, "count": 0}
|
||||||
try:
|
try:
|
||||||
rl_row = (
|
with db.begin_nested():
|
||||||
db.execute(
|
rl_row = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT COUNT(*) AS cnt
|
SELECT COUNT(*) AS cnt
|
||||||
FROM cad_red_lines
|
FROM cad_red_lines
|
||||||
WHERE ST_Intersects(
|
WHERE ST_Intersects(
|
||||||
|
|
@ -2106,11 +2113,11 @@ def analyze_parcel(
|
||||||
ST_GeomFromText(:wkt, 4326)
|
ST_GeomFromText(:wkt, 4326)
|
||||||
)
|
)
|
||||||
"""),
|
"""),
|
||||||
{"wkt": geom_wkt},
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if rl_row:
|
if rl_row:
|
||||||
_rl_cnt = int(rl_row["cnt"])
|
_rl_cnt = int(rl_row["cnt"])
|
||||||
red_lines_block = {
|
red_lines_block = {
|
||||||
|
|
@ -2132,9 +2139,10 @@ def analyze_parcel(
|
||||||
}
|
}
|
||||||
if district_row and district_row["district_name"]:
|
if district_row and district_row["district_name"]:
|
||||||
try:
|
try:
|
||||||
dp_row = (
|
with db.begin_nested():
|
||||||
db.execute(
|
dp_row = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT
|
SELECT
|
||||||
MIN(price_per_m2_rub) AS price_min,
|
MIN(price_per_m2_rub) AS price_min,
|
||||||
MAX(price_per_m2_rub) AS price_max,
|
MAX(price_per_m2_rub) AS price_max,
|
||||||
|
|
@ -2147,11 +2155,11 @@ def analyze_parcel(
|
||||||
AND price_per_m2_rub IS NOT NULL
|
AND price_per_m2_rub IS NOT NULL
|
||||||
AND price_per_m2_rub BETWEEN 30000 AND 600000
|
AND price_per_m2_rub BETWEEN 30000 AND 600000
|
||||||
"""),
|
"""),
|
||||||
{"dn": district_row["district_name"]},
|
{"dn": district_row["district_name"]},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0:
|
if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0:
|
||||||
district_price_block = {
|
district_price_block = {
|
||||||
"district_price_per_m2_min": (
|
"district_price_per_m2_min": (
|
||||||
|
|
@ -2175,9 +2183,10 @@ def analyze_parcel(
|
||||||
"geology_risk_label": None,
|
"geology_risk_label": None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
flood_row = (
|
with db.begin_nested():
|
||||||
db.execute(
|
flood_row = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
SELECT COUNT(*) AS cnt
|
SELECT COUNT(*) AS cnt
|
||||||
FROM cad_risk_zones
|
FROM cad_risk_zones
|
||||||
WHERE ST_Intersects(
|
WHERE ST_Intersects(
|
||||||
|
|
@ -2187,11 +2196,11 @@ def analyze_parcel(
|
||||||
AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%'
|
AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%'
|
||||||
OR risk_type ILIKE '%затоп%')
|
OR risk_type ILIKE '%затоп%')
|
||||||
"""),
|
"""),
|
||||||
{"wkt": geom_wkt},
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
_flood = bool(flood_row and int(flood_row["cnt"]) > 0)
|
_flood = bool(flood_row and int(flood_row["cnt"]) > 0)
|
||||||
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
|
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
|
||||||
_geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
|
_geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
|
||||||
|
|
@ -2214,9 +2223,10 @@ def analyze_parcel(
|
||||||
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
||||||
market_trend: dict[str, Any] | None = None
|
market_trend: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
trend_row = (
|
with db.begin_nested():
|
||||||
db.execute(
|
trend_row = (
|
||||||
text("""
|
db.execute(
|
||||||
|
text("""
|
||||||
WITH district_deals AS (
|
WITH district_deals AS (
|
||||||
SELECT d.period_start_date AS deal_date,
|
SELECT d.period_start_date AS deal_date,
|
||||||
d.price_per_sqm AS price_per_m2
|
d.price_per_sqm AS price_per_m2
|
||||||
|
|
@ -2251,11 +2261,11 @@ def analyze_parcel(
|
||||||
AS prior_n
|
AS prior_n
|
||||||
FROM district_deals
|
FROM district_deals
|
||||||
"""),
|
"""),
|
||||||
{"wkt": geom_wkt},
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
|
if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
|
||||||
recent_p = float(trend_row["recent_avg"])
|
recent_p = float(trend_row["recent_avg"])
|
||||||
prior_p = float(trend_row["prior_avg"])
|
prior_p = float(trend_row["prior_avg"])
|
||||||
|
|
@ -2712,9 +2722,7 @@ def analyze_parcel(
|
||||||
if district_row and district_row["district_name"]:
|
if district_row and district_row["district_name"]:
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
saturation_block = compute_district_saturation(
|
saturation_block = compute_district_saturation(db, district_row["district_name"])
|
||||||
db, district_row["district_name"]
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"saturation block failed for %s (%s): %s",
|
"saturation block failed for %s (%s): %s",
|
||||||
|
|
@ -2942,7 +2950,7 @@ def analyze_parcel(
|
||||||
response_model=ConnectionPointsResponse,
|
response_model=ConnectionPointsResponse,
|
||||||
summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)",
|
summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)",
|
||||||
)
|
)
|
||||||
async def get_parcel_connection_points(
|
def get_parcel_connection_points(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
|
radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
|
||||||
|
|
@ -3106,7 +3114,7 @@ def get_isochrones(
|
||||||
response_model=PoiScoreResponse,
|
response_model=PoiScoreResponse,
|
||||||
summary="POI weighted top-7 (B6)",
|
summary="POI weighted top-7 (B6)",
|
||||||
)
|
)
|
||||||
async def get_poi_score(
|
def get_poi_score(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
|
radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from datetime import date, datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class TradeInEstimateInput(BaseModel):
|
class TradeInEstimateInput(BaseModel):
|
||||||
|
|
@ -23,6 +23,12 @@ class TradeInEstimateInput(BaseModel):
|
||||||
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
|
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
|
||||||
has_balcony: bool | None = None
|
has_balcony: bool | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_floor_within_total(self) -> TradeInEstimateInput:
|
||||||
|
if self.floor > self.total_floors:
|
||||||
|
raise ValueError("floor must be less than or equal to total_floors")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class AnalogLot(BaseModel):
|
class AnalogLot(BaseModel):
|
||||||
address: str
|
address: str
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,28 @@ def _f(value: Any) -> float | None:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# UI шлёт английские классы (ClassLiteral = "Comfort"/"Comfort+"/"Business"/
|
||||||
|
# "Elite"), а domrf_kn_objects.obj_class и objective_corpus_room_month.class
|
||||||
|
# хранят русские названия из скрейпера (domrf_kn.py: "Элит"/"Бизнес"/"Комфорт"/
|
||||||
|
# "Стандарт"/"Типовой"). Без перевода точный матч obj_class = :cls в comparables/
|
||||||
|
# competitors/velocity/elasticity молча даёт ноль строк ("Comfort" != "Комфорт").
|
||||||
|
_CLASS_EN_TO_RU = {
|
||||||
|
"Comfort": "Комфорт",
|
||||||
|
"Comfort+": "Комфорт",
|
||||||
|
"Business": "Бизнес",
|
||||||
|
"Elite": "Элит",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _class_to_db_vocab(target_class: str | None) -> str | None:
|
||||||
|
"""Перевод английского класса из UI в русский словарь БД (obj_class /
|
||||||
|
objective class). Неизвестные значения возвращаем как есть (уже могут быть
|
||||||
|
русскими, либо новый класс — пусть фильтр решает естественно)."""
|
||||||
|
if target_class is None:
|
||||||
|
return None
|
||||||
|
return _CLASS_EN_TO_RU.get(target_class, target_class)
|
||||||
|
|
||||||
|
|
||||||
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
|
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -121,7 +143,12 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
|
||||||
AND area > 0 AND deal_count > 0
|
AND area > 0 AND deal_count > 0
|
||||||
AND (area / deal_count) BETWEEN 15 AND 200
|
AND (area / deal_count) BETWEEN 15 AND 200
|
||||||
AND price_per_sqm > 0
|
AND price_per_sqm > 0
|
||||||
AND period_start_date >= '2025-07-01'
|
-- #1384: скользящее окно вместо захардкоженной даты-пола
|
||||||
|
-- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
|
||||||
|
-- доливки ETL новых report_months → перекос в сторону всё
|
||||||
|
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
|
||||||
|
AND period_start_date >= NOW()
|
||||||
|
- (:months_window || ' months')::INTERVAL
|
||||||
),
|
),
|
||||||
bucketed AS (
|
bucketed AS (
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
|
|
@ -144,7 +171,9 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
|
||||||
ORDER BY bucket
|
ORDER BY bucket
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"region_id": region_id},
|
# 12 мес — размер окна, эквивалентный исходному '2025-07-01' на момент
|
||||||
|
# написания (см. #1384); теперь окно скользит и не растёт со временем.
|
||||||
|
{"region_id": region_id, "months_window": 12},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -993,7 +1022,15 @@ def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]
|
||||||
WHEN f.rooms = 3 THEN '3-комн.'
|
WHEN f.rooms = 3 THEN '3-комн.'
|
||||||
ELSE (f.rooms::text || '-комн.')
|
ELSE (f.rooms::text || '-комн.')
|
||||||
END AS room_label,
|
END AS room_label,
|
||||||
COALESCE(f.rooms, -1) AS sort_key,
|
-- #1358: sort_key должен совпадать с логическим room_label, а
|
||||||
|
-- не с сырым rooms. Нежилой юнит с flat_type LIKE '%нежил%' но
|
||||||
|
-- ненулевым rooms (напр. коммерция rooms=2) иначе уходил в
|
||||||
|
-- отдельную группу sort_key=2, дробя бакет 'Нежилые' на дубли.
|
||||||
|
CASE
|
||||||
|
WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%'
|
||||||
|
OR LOWER(f.flat_type) LIKE '%nonliv%' THEN -1
|
||||||
|
ELSE f.rooms
|
||||||
|
END AS sort_key,
|
||||||
COUNT(*) AS total_count,
|
COUNT(*) AS total_count,
|
||||||
COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
|
COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
|
||||||
OR LOWER(f.status) LIKE '%свобод%')
|
OR LOWER(f.status) LIKE '%свобод%')
|
||||||
|
|
@ -2450,6 +2487,10 @@ def recommend_mix(
|
||||||
"price_p75_per_m2": p75,
|
"price_p75_per_m2": p75,
|
||||||
"units_planned": units_planned,
|
"units_planned": units_planned,
|
||||||
"revenue_planned_rub": revenue_planned,
|
"revenue_planned_rub": revenue_planned,
|
||||||
|
# #1359: сырое area_avg сохраняем, чтобы при success-boost
|
||||||
|
# перераспределить units/revenue под новые share_pct. Удаляется
|
||||||
|
# из ответа перед возвратом.
|
||||||
|
"_area_avg_raw": area_avg,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2458,9 +2499,13 @@ def recommend_mix(
|
||||||
# 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
|
# 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
|
||||||
# Both are required for the live "цена↔темп" calculator on the frontend.
|
# Both are required for the live "цена↔темп" calculator on the frontend.
|
||||||
# Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный
|
# Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный
|
||||||
# баг скрейпера). Если в районе нет ни одного НЕ-NULL obj_class —
|
# баг скрейпера). Если в районе нет ни одного ЖК ИМЕННО запрошенного класса —
|
||||||
# игнорируем target_class фильтр на уровне velocity/elasticity/comparable
|
# игнорируем target_class фильтр на уровне velocity/elasticity/comparable
|
||||||
# запросов, иначе obj_pool пустой и всё падает в fallback.
|
# запросов, иначе obj_pool пустой и всё падает в fallback.
|
||||||
|
# #1375: БД хранит русские названия классов, UI шлёт английские — переводим
|
||||||
|
# перед матчем obj_class = :cls, иначе точный матч молча даёт ноль строк
|
||||||
|
# ("Comfort" != "Комфорт") и comparables/competitors тихо пустеют.
|
||||||
|
target_class_db = _class_to_db_vocab(target_class)
|
||||||
has_class_data = bool(
|
has_class_data = bool(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -2469,18 +2514,20 @@ def recommend_mix(
|
||||||
WHERE region_cd = :rc
|
WHERE region_cd = :rc
|
||||||
AND district_name = :dn
|
AND district_name = :dn
|
||||||
AND obj_class IS NOT NULL
|
AND obj_class IS NOT NULL
|
||||||
|
AND (CAST(:cls AS TEXT) IS NULL OR obj_class = :cls)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"rc": region_code, "dn": district_row["district_name"]},
|
{"rc": region_code, "dn": district_row["district_name"], "cls": target_class_db},
|
||||||
).scalar()
|
).scalar()
|
||||||
)
|
)
|
||||||
target_class_for_geo = target_class if has_class_data else None
|
target_class_for_geo = target_class_db if has_class_data else None
|
||||||
if target_class and not has_class_data:
|
if target_class and not has_class_data:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"obj_class не заполнен для ЖК района {district_row['district_name']}"
|
f"Нет ЖК класса '{target_class}' среди размеченных в районе"
|
||||||
f" — фильтр по классу '{target_class}' игнорируется в velocity/comparable"
|
f" {district_row['district_name']} — фильтр по классу игнорируется в"
|
||||||
" (но class_multiplier из yandex_realty_zk применяется к ценам)."
|
" velocity/comparable (но class_multiplier из yandex_realty_zk"
|
||||||
|
" применяется к ценам)."
|
||||||
)
|
)
|
||||||
vel = _velocity_baseline(
|
vel = _velocity_baseline(
|
||||||
db,
|
db,
|
||||||
|
|
@ -2739,6 +2786,41 @@ def recommend_mix(
|
||||||
if b["bucket"] != top_bucket_name:
|
if b["bucket"] != top_bucket_name:
|
||||||
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
||||||
|
|
||||||
|
# #1359: success-boost изменил share_pct → перераспределяем
|
||||||
|
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
||||||
|
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
||||||
|
# (UI показывал бы 40% доли при units от старых 30%).
|
||||||
|
if area_total_m2:
|
||||||
|
total_units = 0
|
||||||
|
total_revenue = 0.0
|
||||||
|
for b in buckets:
|
||||||
|
area_avg = b["_area_avg_raw"]
|
||||||
|
if area_avg and area_avg > 0:
|
||||||
|
allocated = area_total_m2 * (b["share_pct"] / 100.0)
|
||||||
|
units = max(1, round(allocated / area_avg))
|
||||||
|
b["units_planned"] = units
|
||||||
|
# price_median_per_m2 уже с combined_price_factor —
|
||||||
|
# revenue считаем по скорректированной цене.
|
||||||
|
revenue = round(units * area_avg * b["price_median_per_m2"], 2)
|
||||||
|
b["revenue_planned_rub"] = revenue
|
||||||
|
total_revenue += revenue
|
||||||
|
bucket_velocity = b["velocity_per_month"] or 0.0
|
||||||
|
be_val = b.get("elasticity")
|
||||||
|
bpf = (
|
||||||
|
price_factor**be_val
|
||||||
|
if (be_val is not None and price_factor > 0)
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
adj_vel = bucket_velocity * bpf
|
||||||
|
b["months_to_sellout"] = (
|
||||||
|
round(units / adj_vel, 1) if adj_vel > 0 else None
|
||||||
|
)
|
||||||
|
total_units += units
|
||||||
|
# total_revenue/weighted_avg_price уже включают
|
||||||
|
# combined_price_factor (через per-bucket price_median_per_m2),
|
||||||
|
# повторно домножать НЕ нужно.
|
||||||
|
have_revenue = total_revenue > 0
|
||||||
|
|
||||||
# 5c) Inverse mode: target_months → required price_factor.
|
# 5c) Inverse mode: target_months → required price_factor.
|
||||||
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
|
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
|
||||||
# → агрегатная только когда нужна одна цифра). При smooth-buckets разница
|
# → агрегатная только когда нужна одна цифра). При smooth-buckets разница
|
||||||
|
|
@ -2894,6 +2976,10 @@ def recommend_mix(
|
||||||
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||||||
headline = " · ".join(headline_parts) if headline_parts else None
|
headline = " · ".join(headline_parts) if headline_parts else None
|
||||||
|
|
||||||
|
# #1359: убираем служебное поле перед возвратом (и до передачи в overlay).
|
||||||
|
for b in buckets:
|
||||||
|
b.pop("_area_avg_raw", None)
|
||||||
|
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"scope": {
|
"scope": {
|
||||||
"district": district_row["district_name"],
|
"district": district_row["district_name"],
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ ROBUST к частичному/пустому отчёту (отчёт може
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -150,6 +151,8 @@ def _fmt_number(value: Any) -> str | None:
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return _fmt_thousands(value)
|
return _fmt_thousands(value)
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
|
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
|
||||||
|
return str(value)
|
||||||
if value == int(value):
|
if value == int(value):
|
||||||
return _fmt_thousands(value)
|
return _fmt_thousands(value)
|
||||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,9 @@ explainability/прототипа, не основание для инвест-
|
||||||
|
|
||||||
КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты
|
КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты
|
||||||
неопределённее, поэтому Δ ставки растёт линейно с горизонтом —
|
неопределённее, поэтому Δ ставки растёт линейно с горизонтом —
|
||||||
delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). На h=12 мес
|
delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). При
|
||||||
конверт = base_delta, на h=24 мес ≈ base_delta × (1 + 0.5×_HORIZON_WIDEN_PER_YEAR).
|
_HORIZON_WIDEN_PER_YEAR=0.5: на h=12 мес конверт = base_delta × 1.5, на h=24 мес
|
||||||
|
= base_delta × 2.0 (на каждый год горизонта +50% к base_delta).
|
||||||
Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится ≥ 0 (нет
|
Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится ≥ 0 (нет
|
||||||
отрицательной ключевой ставки) — на дальнем агрессивном горизонте конверт может
|
отрицательной ключевой ставки) — на дальнем агрессивном горизонте конверт может
|
||||||
упереть ставку в 0.
|
упереть ставку в 0.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from .provider import (
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
LLMProviderError,
|
LLMProviderError,
|
||||||
LLMRateLimitedError,
|
LLMRateLimitedError,
|
||||||
|
LLMTimeoutError,
|
||||||
OpenAIProvider,
|
OpenAIProvider,
|
||||||
ProviderResponse,
|
ProviderResponse,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
|
|
@ -244,7 +245,7 @@ def _call_with_retries(
|
||||||
except LLMProviderError as e:
|
except LLMProviderError as e:
|
||||||
# Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже
|
# Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже
|
||||||
# «съел» бюджет времени; сетевые/4xx обычно не лечатся повтором тут).
|
# «съел» бюджет времени; сетевые/4xx обычно не лечатся повтором тут).
|
||||||
reason = "timeout" if e.__class__.__name__ == "LLMTimeoutError" else "provider_error"
|
reason = "timeout" if isinstance(e, LLMTimeoutError) else "provider_error"
|
||||||
logger.warning("llm: provider error (%s) → fallback: %s", reason, e)
|
logger.warning("llm: provider error (%s) → fallback: %s", reason, e)
|
||||||
return LLMResult.fallback(reason)
|
return LLMResult.fallback(reason)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,8 @@ def list_izyatie_documents(url: str = _IZYATIE_URL) -> list[dict[str, str]]:
|
||||||
# Берём только /file/<hash>-ссылки.
|
# Берём только /file/<hash>-ссылки.
|
||||||
if not _RE_FILE_HREF.match(href):
|
if not _RE_FILE_HREF.match(href):
|
||||||
continue
|
continue
|
||||||
title: str = a_tag.get_text(strip=True) or href
|
# Заголовок: текст якоря → title-атрибут → href как последний фолбэк.
|
||||||
# Если заголовок пустой — берём title-атрибут или href.
|
title: str = a_tag.get_text(strip=True) or a_tag.get("title") or href
|
||||||
if not title:
|
|
||||||
title = a_tag.get("title", href)
|
|
||||||
absolute_url = urllib.parse.urljoin(_BASE_URL, href)
|
absolute_url = urllib.parse.urljoin(_BASE_URL, href)
|
||||||
docs.append({"title": title, "url": absolute_url})
|
docs.append({"title": title, "url": absolute_url})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,38 +111,91 @@ _COMPETITORS_IN_RADIUS_SQL = text("""
|
||||||
# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе
|
# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе
|
||||||
# CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval).
|
# CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval).
|
||||||
|
|
||||||
|
#
|
||||||
|
# Fix #1391: fan-out по objective_complex_mapping завышал SUM-метрики. UNIQUE-ключ
|
||||||
|
# mapping — (objective_complex_name, objective_group), поэтому один project_name может
|
||||||
|
# матчиться на N mapping-строк (разные группы / domrf_obj_id). Прямой
|
||||||
|
# JOIN crm × cm дублировал каждую crm-строку N раз → SUM(deals_total_count),
|
||||||
|
# weighted-avg числители/знаменатели и deals_window раздувались в N раз. DISTINCT-метрики
|
||||||
|
# (competitor_obj_ids / competitor_count) не страдали, но SUM — да.
|
||||||
|
# Решение: deals агрегируем per (project_name, room_bucket) в CTE `crm_agg`
|
||||||
|
# (без mapping-джойна → fan-out невозможен). Список project_name'ов в радиусе берём из
|
||||||
|
# CTE `mapped_projects` (DISTINCT по objective_complex_name) и джойним один-к-одному —
|
||||||
|
# каждый project_name вносит свои сделки ровно один раз. competitor_obj_ids /
|
||||||
|
# competitor_count считаем отдельно в `obj_per_bucket` (через unnest mapping-obj_id),
|
||||||
|
# чтобы fan-out по obj_id не попал в SUM-агрегаты, и приджойниваем по room_bucket.
|
||||||
|
|
||||||
_INLINE_VELOCITY_SQL = text("""
|
_INLINE_VELOCITY_SQL = text("""
|
||||||
SELECT
|
WITH crm_agg AS (
|
||||||
CASE
|
SELECT
|
||||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
crm.project_name,
|
||||||
ELSE crm.room_bucket
|
CASE
|
||||||
END AS room_bucket,
|
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||||
SUM(crm.deals_total_count) AS deals_window,
|
ELSE crm.room_bucket
|
||||||
COALESCE(
|
END AS room_bucket,
|
||||||
|
SUM(crm.deals_total_count) AS deals_window,
|
||||||
SUM(crm.deals_total_avg_area_m2 * crm.deals_total_count)
|
SUM(crm.deals_total_avg_area_m2 * crm.deals_total_count)
|
||||||
/ NULLIF(SUM(crm.deals_total_count), 0),
|
AS area_weighted_sum,
|
||||||
0
|
|
||||||
)::numeric(10, 2) AS avg_area_m2,
|
|
||||||
COALESCE(
|
|
||||||
SUM(crm.deals_total_avg_price_thousand_rub_per_m2 * crm.deals_total_count)
|
SUM(crm.deals_total_avg_price_thousand_rub_per_m2 * crm.deals_total_count)
|
||||||
/ NULLIF(SUM(crm.deals_total_count), 0),
|
AS price_weighted_sum,
|
||||||
0
|
MIN(crm.report_month) AS window_start,
|
||||||
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
|
MAX(crm.report_month) AS window_end
|
||||||
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids,
|
FROM objective_corpus_room_month crm
|
||||||
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count,
|
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date
|
||||||
MIN(crm.report_month) AS window_start,
|
AND crm.room_bucket IS NOT NULL
|
||||||
MAX(crm.report_month) AS window_end
|
GROUP BY crm.project_name,
|
||||||
FROM objective_corpus_room_month crm
|
CASE
|
||||||
JOIN objective_complex_mapping cm
|
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||||
ON cm.objective_complex_name = crm.project_name
|
ELSE crm.room_bucket
|
||||||
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date
|
END
|
||||||
AND cm.domrf_obj_id = ANY(:competitor_obj_ids)
|
),
|
||||||
AND crm.room_bucket IS NOT NULL
|
mapped_projects AS (
|
||||||
GROUP BY
|
SELECT DISTINCT cm.objective_complex_name AS project_name
|
||||||
CASE
|
FROM objective_complex_mapping cm
|
||||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids)
|
||||||
ELSE crm.room_bucket
|
),
|
||||||
END
|
deals_per_bucket AS (
|
||||||
|
SELECT
|
||||||
|
a.room_bucket,
|
||||||
|
SUM(a.deals_window) AS deals_window,
|
||||||
|
COALESCE(
|
||||||
|
SUM(a.area_weighted_sum)
|
||||||
|
/ NULLIF(SUM(a.deals_window), 0),
|
||||||
|
0
|
||||||
|
)::numeric(10, 2) AS avg_area_m2,
|
||||||
|
COALESCE(
|
||||||
|
SUM(a.price_weighted_sum)
|
||||||
|
/ NULLIF(SUM(a.deals_window), 0),
|
||||||
|
0
|
||||||
|
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
|
||||||
|
MIN(a.window_start) AS window_start,
|
||||||
|
MAX(a.window_end) AS window_end
|
||||||
|
FROM crm_agg a
|
||||||
|
JOIN mapped_projects mp ON mp.project_name = a.project_name
|
||||||
|
GROUP BY a.room_bucket
|
||||||
|
),
|
||||||
|
obj_per_bucket AS (
|
||||||
|
SELECT
|
||||||
|
a.room_bucket,
|
||||||
|
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids,
|
||||||
|
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count
|
||||||
|
FROM crm_agg a
|
||||||
|
JOIN objective_complex_mapping cm
|
||||||
|
ON cm.objective_complex_name = a.project_name
|
||||||
|
WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids)
|
||||||
|
GROUP BY a.room_bucket
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
d.room_bucket AS room_bucket,
|
||||||
|
d.deals_window AS deals_window,
|
||||||
|
d.avg_area_m2 AS avg_area_m2,
|
||||||
|
d.avg_price_per_m2_rub AS avg_price_per_m2_rub,
|
||||||
|
o.competitor_obj_ids AS competitor_obj_ids,
|
||||||
|
o.competitor_count AS competitor_count,
|
||||||
|
d.window_start AS window_start,
|
||||||
|
d.window_end AS window_end
|
||||||
|
FROM deals_per_bucket d
|
||||||
|
JOIN obj_per_bucket o ON o.room_bucket = d.room_bucket
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
|
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,18 @@ def cad_exists_in_db(db: Session, cad_num: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
|
def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
|
||||||
"""Найти существующий on-demand job (queued/running) для этого cad.
|
"""Найти существующий on-demand job (queued/running/paused) для этого cad.
|
||||||
|
|
||||||
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
|
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
|
||||||
секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
|
секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
|
||||||
отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через
|
отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через
|
||||||
`fetch_status` отличит этот случай как `not_in_nspd`.
|
`fetch_status` отличит этот случай как `not_in_nspd`.
|
||||||
|
|
||||||
|
NB (issue #1356): 'paused' тоже считается active. Job переходит в 'paused'
|
||||||
|
при WAF (consecutive>=8) или Celery soft_time_limit (6h) — нетронутые targets
|
||||||
|
остаются 'pending', а job авто-резюмится через cleanup_zombies/worker_ready.
|
||||||
|
Это in-flight состояние, а не «не найдено»: без него paused job проваливался
|
||||||
|
бы в неверный ответ 'not_in_nspd' в `fetch_status`.
|
||||||
"""
|
"""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -110,7 +116,7 @@ def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
|
||||||
FROM nspd_geo_jobs j
|
FROM nspd_geo_jobs j
|
||||||
JOIN nspd_geo_targets t ON t.job_id = j.job_id
|
JOIN nspd_geo_targets t ON t.job_id = j.job_id
|
||||||
WHERE j.source_kind = :src
|
WHERE j.source_kind = :src
|
||||||
AND j.status IN ('queued', 'running')
|
AND j.status IN ('queued', 'running', 'paused')
|
||||||
AND t.cad_num = :c
|
AND t.cad_num = :c
|
||||||
ORDER BY j.created_at DESC
|
ORDER BY j.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|
@ -342,6 +348,18 @@ def fetch_status(db: Session, cad_num: str) -> dict:
|
||||||
or "НСПД временно недоступен. Попробуйте через минуту.",
|
or "НСПД временно недоступен. Попробуйте через минуту.",
|
||||||
"eta_seconds": None,
|
"eta_seconds": None,
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
# Non-terminal target (issue #1356): чаще всего 'pending' у paused/queued
|
||||||
|
# job, который ещё не дошёл до этого cad. Активный job обычно ловится выше
|
||||||
|
# в `find_active_on_demand_job`, но на гонке статусов сюда может прилететь
|
||||||
|
# job с pending-target — это in-flight, а НЕ 'not_in_nspd'. Консервативно
|
||||||
|
# отдаём 'fetching', чтобы frontend продолжил polling, а не показал 404.
|
||||||
|
return {
|
||||||
|
"status": "fetching",
|
||||||
|
"job_id": recent["job_id"],
|
||||||
|
"error_msg": None,
|
||||||
|
"eta_seconds": 15,
|
||||||
|
}
|
||||||
|
|
||||||
# No job at all — first request without prior history
|
# No job at all — first request without prior history
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) ->
|
||||||
tuple(sorted(request.exclude_obj_ids)),
|
tuple(sorted(request.exclude_obj_ids)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Маппинг time_window → число месяцев (float для деления velocity)
|
# Маппинг time_window → число месяцев (float для деления velocity)
|
||||||
_TIME_WINDOW_MONTHS: dict[str, float] = {
|
_TIME_WINDOW_MONTHS: dict[str, float] = {
|
||||||
"last_month": 1.0,
|
"last_month": 1.0,
|
||||||
|
|
@ -433,10 +434,6 @@ _COMPETITORS_SQL = text("""
|
||||||
FROM distances d
|
FROM distances d
|
||||||
LEFT JOIN velocity v ON v.obj_id = d.obj_id
|
LEFT JOIN velocity v ON v.obj_id = d.obj_id
|
||||||
WHERE d.distance_m <= CAST(:radius_m AS float)
|
WHERE d.distance_m <= CAST(:radius_m AS float)
|
||||||
AND (
|
|
||||||
CAST(:obj_class_filter AS text) IS NULL
|
|
||||||
OR d.obj_class = CAST(:obj_class_filter AS text)
|
|
||||||
)
|
|
||||||
ORDER BY d.distance_m ASC
|
ORDER BY d.distance_m ASC
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -571,7 +568,6 @@ def get_competitors(
|
||||||
"radius_m": request.radius_km * 1000.0,
|
"radius_m": request.radius_km * 1000.0,
|
||||||
"time_window_months": time_window_months,
|
"time_window_months": time_window_months,
|
||||||
"window_interval": window_interval,
|
"window_interval": window_interval,
|
||||||
"obj_class_filter": request.obj_class_filter,
|
|
||||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -591,6 +587,24 @@ def get_competitors(
|
||||||
if exclude_set:
|
if exclude_set:
|
||||||
rows = [r for r in rows if int(r["obj_id"]) not in exclude_set]
|
rows = [r for r in rows if int(r["obj_id"]) not in exclude_set]
|
||||||
|
|
||||||
|
# ── 3b. Фильтр класса объекта (#1340) ────────────────────────────────────
|
||||||
|
# obj_class_filter из API приходит англ. (economy/comfort/business), а в БД
|
||||||
|
# domrf_kn_objects.obj_class хранит РУССКИЙ канон (Стандарт/Комфорт/Бизнес/…
|
||||||
|
# см. domrf_kn._OBJ_CLASS_PATTERNS). Прямое SQL-равенство 'comfort'='Комфорт'
|
||||||
|
# всегда false → пустой список. Нормализуем обе стороны через _normalize_class
|
||||||
|
# и сравниваем по индексу _CLASS_ORDER, поэтому economy (индекс 0) корректно
|
||||||
|
# матчит и 'Стандарт', и 'Типовой' (оба индекс 0).
|
||||||
|
if request.obj_class_filter is not None:
|
||||||
|
target_norm = _normalize_class(request.obj_class_filter)
|
||||||
|
target_order = _CLASS_ORDER.get(target_norm) if target_norm else None
|
||||||
|
if target_order is not None:
|
||||||
|
rows = [
|
||||||
|
r
|
||||||
|
for r in rows
|
||||||
|
if (norm := _normalize_class(r["obj_class"])) is not None
|
||||||
|
and _CLASS_ORDER[norm] == target_order
|
||||||
|
]
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return CompetitorsResponse(
|
return CompetitorsResponse(
|
||||||
competitors=[],
|
competitors=[],
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,27 @@ def compute_gate_verdict(
|
||||||
# cad_zouit fallback path: classify by type_zone keywords (#232).
|
# cad_zouit fallback path: classify by type_zone keywords (#232).
|
||||||
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
|
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
|
||||||
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
|
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
|
||||||
# #1070: «сетевое обременение» — охранная зона инж.сети — выделенный
|
# #1070: классификация «сетевого обременения» (охранная зона инж.сети).
|
||||||
# blocker-код с видом сети (тепло/электро/…) в детали. Классификация
|
# Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind);
|
||||||
# пришла из _get_cad_zouit_overlaps (is_network_zone/network_kind); если
|
# если overlap из старого пути без флага — определяем здесь же.
|
||||||
# overlap пришёл из старого пути без флага — определяем здесь же.
|
|
||||||
net_kind = overlap.get("network_kind")
|
net_kind = overlap.get("network_kind")
|
||||||
if net_kind is None and overlap.get("is_network_zone") is None:
|
if net_kind is None and overlap.get("is_network_zone") is None:
|
||||||
net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer"))
|
net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer"))
|
||||||
if net_kind is not None:
|
# Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная'
|
||||||
|
# специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной
|
||||||
|
# станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое
|
||||||
|
# обременение (#1070, выделенный код раньше широких подстрок), затем общие
|
||||||
|
# blocker-keywords, иначе — generic warning.
|
||||||
|
if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
|
||||||
|
warnings.append(
|
||||||
|
Warning(
|
||||||
|
code="ZOUIT_CAD_SZZ",
|
||||||
|
detail=(
|
||||||
|
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif net_kind is not None:
|
||||||
kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind)
|
kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind)
|
||||||
blockers.append(
|
blockers.append(
|
||||||
Blocker(
|
Blocker(
|
||||||
|
|
@ -188,15 +201,6 @@ def compute_gate_verdict(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
|
|
||||||
warnings.append(
|
|
||||||
Warning(
|
|
||||||
code="ZOUIT_CAD_SZZ",
|
|
||||||
detail=(
|
|
||||||
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
Warning(
|
Warning(
|
||||||
|
|
@ -209,8 +213,20 @@ def compute_gate_verdict(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# NSPD dump path: subcategory-based logic (backward-compat).
|
# NSPD dump path: subcategory-based logic (backward-compat).
|
||||||
sub = overlap.get("subcategory")
|
# subcategory из _get_zouit_overlaps = `subcategory or type_zone` → может быть
|
||||||
if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES:
|
# строкой ('17') или fallback на type_zone (#1360). Нормализуем к int как
|
||||||
|
# _get_zouit_engineering_overlaps, иначе blocker subcategory-17 тихо
|
||||||
|
# деградирует до warning.
|
||||||
|
sub_raw = overlap.get("subcategory")
|
||||||
|
sub: int | None
|
||||||
|
if isinstance(sub_raw, bool):
|
||||||
|
sub = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
sub = int(sub_raw) # type: ignore[arg-type]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
sub = None
|
||||||
|
if sub is not None and sub in BLOCKER_SUBCATEGORIES:
|
||||||
blockers.append(
|
blockers.append(
|
||||||
Blocker(
|
Blocker(
|
||||||
code=f"ZOUIT_OVERLAP_SUB{sub}",
|
code=f"ZOUIT_OVERLAP_SUB{sub}",
|
||||||
|
|
@ -218,9 +234,17 @@ def compute_gate_verdict(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Сохраняем исходную метку (str/int) в warning-коде если нормализация
|
||||||
|
# не дала int — иначе теряется человекочитаемый ярлык subcategory.
|
||||||
|
if sub is not None:
|
||||||
|
sub_label: int | str = sub
|
||||||
|
elif sub_raw is not None:
|
||||||
|
sub_label = sub_raw
|
||||||
|
else:
|
||||||
|
sub_label = "unknown"
|
||||||
warnings.append(
|
warnings.append(
|
||||||
Warning(
|
Warning(
|
||||||
code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}",
|
code=f"ZOUIT_SUB{sub_label}",
|
||||||
detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}",
|
detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,15 @@ async def fetch_overpass_noise() -> list[dict]:
|
||||||
all_elements: list[dict] = []
|
all_elements: list[dict] = []
|
||||||
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
|
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
|
||||||
for key, value, source_type, road_class in _NOISE_QUERIES:
|
for key, value, source_type, road_class in _NOISE_QUERIES:
|
||||||
# aerodrome — node; точки подключения инж.сетей — nwr (node+way);
|
# aerodrome — nwr (node+way): в OSM аэродромы/аэропорты (Кольцово,
|
||||||
|
# IATA SVX) почти всегда замаплены как way-полигон, а не одиночный
|
||||||
|
# node. Запрос только по node возвращал ~0 элементов → авиашум молча
|
||||||
|
# терялся в скоринге (#1350). nwr ловит оба варианта; way-полигон
|
||||||
|
# граница уходит в _way_to_linestring_wkt и корректно участвует в
|
||||||
|
# ST_Distance-проверках. Точки подключения инж.сетей — тоже nwr;
|
||||||
# остальное (дороги/ж-д/трубы/ЛЭП-линии) — way.
|
# остальное (дороги/ж-д/трубы/ЛЭП-линии) — way.
|
||||||
if key == "aeroway":
|
if key == "aeroway":
|
||||||
el_type = "node"
|
el_type = "nwr"
|
||||||
elif value in _UTILITY_POINT_VALUES:
|
elif value in _UTILITY_POINT_VALUES:
|
||||||
el_type = "nwr"
|
el_type = "nwr"
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ _CURRENT_SQL = text(
|
||||||
SELECT o.*
|
SELECT o.*
|
||||||
FROM domrf_kn_objects o
|
FROM domrf_kn_objects o
|
||||||
WHERE o.dev_id IS NOT NULL
|
WHERE o.dev_id IS NOT NULL
|
||||||
AND o.dev_id ~ '^[0-9]+'
|
AND o.dev_id ~ '^[0-9]+(_|$)'
|
||||||
AND CAST(split_part(o.dev_id, '_', 1) AS integer) = ANY(CAST(:ids AS integer[]))
|
AND CAST(split_part(o.dev_id, '_', 1) AS integer) = ANY(CAST(:ids AS integer[]))
|
||||||
),
|
),
|
||||||
latest AS (
|
latest AS (
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,13 @@ async def fetch_overpass() -> list[dict]:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
elements: list[dict] = r.json().get("elements", [])
|
elements: list[dict] = r.json().get("elements", [])
|
||||||
logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements))
|
logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements))
|
||||||
|
# Привязываем category именно к тому per-category запросу, под который
|
||||||
|
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
||||||
|
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
||||||
|
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
||||||
|
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
||||||
|
for el in elements:
|
||||||
|
el["_gd_category"] = category
|
||||||
all_elements.extend(elements)
|
all_elements.extend(elements)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Не падаем на одной категории — логируем и продолжаем
|
# Не падаем на одной категории — логируем и продолжаем
|
||||||
|
|
@ -117,13 +124,17 @@ def sync_poi_to_db() -> dict[str, int]:
|
||||||
inserted = 0
|
inserted = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
skipped_old = 0
|
skipped_old = 0
|
||||||
|
skipped = 0
|
||||||
fetched = len(elements)
|
fetched = len(elements)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
for el in elements:
|
for el in elements:
|
||||||
tags: dict[str, str] = el.get("tags") or {}
|
tags: dict[str, str] = el.get("tags") or {}
|
||||||
category = _classify(tags)
|
# category проставлена в fetch_overpass под тот per-category запрос, под
|
||||||
|
# который элемент пришёл (multi-tag элемент приходит несколько раз, каждая
|
||||||
|
# копия со своей category). Fallback на _classify для прямых вызовов. См. #1372.
|
||||||
|
category = el.get("_gd_category") or _classify(tags)
|
||||||
if not category:
|
if not category:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -155,44 +166,59 @@ def sync_poi_to_db() -> dict[str, int]:
|
||||||
if last_edit and last_edit < two_years_ago:
|
if last_edit and last_edit < two_years_ago:
|
||||||
skipped_old += 1
|
skipped_old += 1
|
||||||
|
|
||||||
result = db.execute(
|
try:
|
||||||
text("""
|
with db.begin_nested(): # SAVEPOINT — откат только этой записи
|
||||||
INSERT INTO osm_poi_ekb
|
result = db.execute(
|
||||||
(osm_id, osm_type, category, name, lat, lon, geom,
|
text("""
|
||||||
last_osm_edit_date, tags, fetched_at)
|
INSERT INTO osm_poi_ekb
|
||||||
VALUES (:osm_id, :osm_type, :category, :name, :lat, :lon,
|
(osm_id, osm_type, category, name, lat, lon, geom,
|
||||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326),
|
last_osm_edit_date, tags, fetched_at)
|
||||||
:last_edit, CAST(:tags AS jsonb), NOW())
|
VALUES (:osm_id, :osm_type, :category, :name, :lat, :lon,
|
||||||
ON CONFLICT (osm_type, osm_id, category) DO UPDATE
|
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326),
|
||||||
SET name = EXCLUDED.name,
|
:last_edit, CAST(:tags AS jsonb), NOW())
|
||||||
lat = EXCLUDED.lat,
|
ON CONFLICT (osm_type, osm_id, category) DO UPDATE
|
||||||
lon = EXCLUDED.lon,
|
SET name = EXCLUDED.name,
|
||||||
geom = EXCLUDED.geom,
|
lat = EXCLUDED.lat,
|
||||||
last_osm_edit_date = EXCLUDED.last_osm_edit_date,
|
lon = EXCLUDED.lon,
|
||||||
tags = EXCLUDED.tags,
|
geom = EXCLUDED.geom,
|
||||||
fetched_at = NOW()
|
last_osm_edit_date = EXCLUDED.last_osm_edit_date,
|
||||||
RETURNING (xmax = 0) AS is_insert
|
tags = EXCLUDED.tags,
|
||||||
"""),
|
fetched_at = NOW()
|
||||||
{
|
RETURNING (xmax = 0) AS is_insert
|
||||||
"osm_id": osm_id,
|
"""),
|
||||||
"osm_type": osm_type,
|
{
|
||||||
"category": category,
|
"osm_id": osm_id,
|
||||||
"name": tags.get("name"),
|
"osm_type": osm_type,
|
||||||
"lat": lat,
|
"category": category,
|
||||||
"lon": lon,
|
"name": tags.get("name"),
|
||||||
"last_edit": last_edit,
|
"lat": lat,
|
||||||
"tags": json.dumps(tags, ensure_ascii=False),
|
"lon": lon,
|
||||||
},
|
"last_edit": last_edit,
|
||||||
).scalar()
|
"tags": json.dumps(tags, ensure_ascii=False),
|
||||||
|
},
|
||||||
if result:
|
).scalar()
|
||||||
inserted += 1
|
if result:
|
||||||
else:
|
inserted += 1
|
||||||
updated += 1
|
else:
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
# Дефектный OSM-элемент (битый tags-jsonb, нарушение constraint, PostGIS-
|
||||||
|
# ошибка у way/relation без корректного center) не должен валить весь
|
||||||
|
# weekly-sync. SAVEPOINT откатывает только эту строку, продолжаем
|
||||||
|
# с остальными. См. #1343 и .claude/rules/backend.md (SAVEPOINT pattern).
|
||||||
|
logger.warning(
|
||||||
|
"poi_sync insert failed for %s/%s (category=%s): %s",
|
||||||
|
osm_type,
|
||||||
|
osm_id,
|
||||||
|
category,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
logger.exception("poi_sync: unexpected error, outer tx rolled back: %s", e)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
@ -202,4 +228,5 @@ def sync_poi_to_db() -> dict[str, int]:
|
||||||
"inserted": inserted,
|
"inserted": inserted,
|
||||||
"updated": updated,
|
"updated": updated,
|
||||||
"skipped_old": skipped_old,
|
"skipped_old": skipped_old,
|
||||||
|
"skipped": skipped,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -939,11 +939,13 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
||||||
if parcel_wkt is None:
|
if parcel_wkt is None:
|
||||||
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||||
|
|
||||||
# Проверяем наличие дампа
|
# Проверяем наличие дампа. harvest_error читаем чтобы не отдавать error-only
|
||||||
|
# строку как dump_available=True (#1371): _upsert_dump при частичном сбое
|
||||||
|
# пишет строку с harvest_error != NULL и нулевыми счётчиками.
|
||||||
dump_row = db.execute(
|
dump_row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT fetched_at_utc, total_features
|
SELECT fetched_at_utc, total_features, harvest_error
|
||||||
FROM nspd_quarter_dumps
|
FROM nspd_quarter_dumps
|
||||||
WHERE quarter_cad = :q
|
WHERE quarter_cad = :q
|
||||||
ORDER BY fetched_at_utc DESC
|
ORDER BY fetched_at_utc DESC
|
||||||
|
|
@ -953,8 +955,7 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
||||||
{"q": quarter},
|
{"q": quarter},
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if dump_row is None:
|
def _empty_unavailable(fetched_at_iso: str | None) -> dict[str, Any]:
|
||||||
_trigger_harvest(quarter)
|
|
||||||
return {
|
return {
|
||||||
"engineering_structures": [],
|
"engineering_structures": [],
|
||||||
"zouit_engineering_overlaps": [],
|
"zouit_engineering_overlaps": [],
|
||||||
|
|
@ -965,15 +966,32 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
||||||
"total_structures_in_radius": 0,
|
"total_structures_in_radius": 0,
|
||||||
},
|
},
|
||||||
"dump_available": False,
|
"dump_available": False,
|
||||||
"dump_fetched_at": None,
|
"dump_fetched_at": fetched_at_iso,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dump_row is None:
|
||||||
|
_trigger_harvest(quarter)
|
||||||
|
return _empty_unavailable(None)
|
||||||
|
|
||||||
fetched_at = dump_row[0]
|
fetched_at = dump_row[0]
|
||||||
if fetched_at is not None and getattr(fetched_at, "isoformat", None):
|
if fetched_at is not None and getattr(fetched_at, "isoformat", None):
|
||||||
dump_fetched_at: str | None = fetched_at.isoformat()
|
dump_fetched_at: str | None = fetched_at.isoformat()
|
||||||
else:
|
else:
|
||||||
dump_fetched_at = str(fetched_at) if fetched_at is not None else None
|
dump_fetched_at = str(fetched_at) if fetched_at is not None else None
|
||||||
|
|
||||||
|
# Error-only или устаревший дамп — отдаём dump_available=False и
|
||||||
|
# перезапускаем harvest (согласовано с get_quarter_dump_data, #1371).
|
||||||
|
harvest_error: str | None = dump_row[2]
|
||||||
|
is_stale = False
|
||||||
|
if fetched_at is not None and getattr(fetched_at, "tzinfo", "missing") != "missing":
|
||||||
|
fetched_at_aware = (
|
||||||
|
fetched_at if fetched_at.tzinfo is not None else fetched_at.replace(tzinfo=UTC)
|
||||||
|
)
|
||||||
|
is_stale = (datetime.now(UTC) - fetched_at_aware) > timedelta(days=_DUMP_MAX_AGE_DAYS)
|
||||||
|
if harvest_error is not None or is_stale:
|
||||||
|
_trigger_harvest(quarter)
|
||||||
|
return _empty_unavailable(dump_fetched_at)
|
||||||
|
|
||||||
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
|
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
|
||||||
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
|
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,14 @@ def compute_velocity(
|
||||||
|
|
||||||
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
|
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
|
||||||
months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0)
|
months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0)
|
||||||
|
# Активные mapped-конкуренты: маппинг + реальные продажи в окне (months_with_data>0).
|
||||||
|
# LEFT JOIN на crm даёт строку has_mapping=TRUE с total_sqm=NULL→0, months=0 для
|
||||||
|
# замаппленных-но-непродающих ЖК — их НЕ считаем при делении/нормализации (#1354, #1382).
|
||||||
|
active_sales_rows = [
|
||||||
|
r
|
||||||
|
for r in mapped_sales_rows
|
||||||
|
if int(r["months_with_data"] or 0) > 0 and float(r["total_sqm"] or 0.0) > 0
|
||||||
|
]
|
||||||
period_start_dates = [r["period_start"] for r in mapped_sales_rows if r["period_start"]]
|
period_start_dates = [r["period_start"] for r in mapped_sales_rows if r["period_start"]]
|
||||||
period_end_dates = [r["period_end"] for r in mapped_sales_rows if r["period_end"]]
|
period_end_dates = [r["period_end"] for r in mapped_sales_rows if r["period_end"]]
|
||||||
period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else ""
|
period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else ""
|
||||||
|
|
@ -429,17 +437,23 @@ def compute_velocity(
|
||||||
velocity_source="none",
|
velocity_source="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
|
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
|
||||||
# Чем больше конкурентов с данными — тем весомее результат.
|
# Делить суммарный объём на max(месяцев) нельзя: при разнородных окнах
|
||||||
monthly_velocity = total_sqm / months_observed
|
# (старые ЖК 6 мес, новые 1-2 мес) это размазывает объём новых по всему окну
|
||||||
|
# и занижает совокупную месячную скорость района.
|
||||||
|
monthly_velocity = sum(
|
||||||
|
float(r["total_sqm"] or 0.0) / int(r["months_with_data"]) for r in active_sales_rows
|
||||||
|
)
|
||||||
|
|
||||||
# ── Step 3: нормализация → score 0..1 ────────────────────────────────────
|
# ── Step 3: нормализация → score 0..1 ────────────────────────────────────
|
||||||
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
|
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
|
||||||
# Если в радиусе продаётся N × ekb_median → рынок горячий.
|
# Если в радиусе продаётся N × ekb_median → рынок горячий.
|
||||||
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
|
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
|
||||||
# Cap 2×median = «насыщен». Итоговый score 0..1.
|
# Cap 2×median = «насыщен». Итоговый score 0..1.
|
||||||
# n_with_sales — только mapped конкуренты (у unmapped данных нет).
|
# n_with_sales — только конкуренты с реальными продажами в окне (#1382).
|
||||||
n_with_sales = len(mapped_sales_rows)
|
# mapped-но-непродающие ЖК (has_mapping=TRUE, total_sqm=0) исключены: иначе
|
||||||
|
# знаменатель раздувается, а числитель их не учитывает → systematic занижение.
|
||||||
|
n_with_sales = len(active_sales_rows)
|
||||||
denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
|
denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
|
||||||
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))
|
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,11 @@ forecast/climate до Phase A).
|
||||||
Все исключения httpx/json/KeyError ловятся → `logger.warning` + negative-cache + None.
|
Все исключения httpx/json/KeyError ловятся → `logger.warning` + negative-cache + None.
|
||||||
|
|
||||||
THREAD-SAFETY: analyze_parcel — sync def, под Uvicorn идёт в threadpool. Каждый кэш
|
THREAD-SAFETY: analyze_parcel — sync def, под Uvicorn идёт в threadpool. Каждый кэш
|
||||||
защищён своим `threading.Lock`. Single-flight: под lock'ом check-then-fetch-then-store,
|
защищён своим `threading.Lock`, который удерживается ТОЛЬКО на время check/store — сам
|
||||||
поэтому конкурентный cold-start на ОДИН ключ делает ровно один сетевой вызов.
|
сетевой httpx-вызов идёт ВНЕ lock'а (#1370), иначе все analyze одного cache-типа
|
||||||
|
сериализуются на время вызова (lock — per-cache-type, не per-key). Trade-off: конкурентный
|
||||||
|
cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write wins),
|
||||||
|
что приемлемо — вызовы идемпотентны, а TTL длинный.
|
||||||
|
|
||||||
time.monotonic используется вместо time.time — устойчиво к скачкам системного времени
|
time.monotonic используется вместо time.time — устойчиво к скачкам системного времени
|
||||||
(NTP sync, ручной выставление). Тесты могут подменять `weather_cache._now` для проталки
|
(NTP sync, ручной выставление). Тесты могут подменять `weather_cache._now` для проталки
|
||||||
|
|
@ -258,13 +261,16 @@ def get_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
entry = _FORECAST_CACHE.get(key)
|
entry = _FORECAST_CACHE.get(key)
|
||||||
if entry is not None and entry[1] > now:
|
if entry is not None and entry[1] > now:
|
||||||
return entry[0]
|
return entry[0]
|
||||||
# MISS или истёк → внутри lock'а делаем сетевой вызов (single-flight: 16
|
# MISS или истёк → сетевой вызов ВНЕ lock'а, иначе все analyze одного cache-типа
|
||||||
# потоков на один ключ → один реальный запрос).
|
# (даже для разных координат) сериализуются на время httpx-вызова (#1370). Ценой —
|
||||||
value = _fetch_weather_remote(lat, lon)
|
# cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write
|
||||||
ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S
|
# wins при store ниже), что приемлемо: вызовы идемпотентны, TTL длинный.
|
||||||
|
value = _fetch_weather_remote(lat, lon)
|
||||||
|
ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||||||
|
with _FORECAST_LOCK:
|
||||||
_evict_one_if_full(_FORECAST_CACHE)
|
_evict_one_if_full(_FORECAST_CACHE)
|
||||||
_FORECAST_CACHE[key] = (value, _now() + ttl)
|
_FORECAST_CACHE[key] = (value, _now() + ttl)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
|
def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
|
|
@ -280,19 +286,22 @@ def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None
|
||||||
entry = _CLIMATE_CACHE.get(key)
|
entry = _CLIMATE_CACHE.get(key)
|
||||||
if entry is not None and entry[1] > now:
|
if entry is not None and entry[1] > now:
|
||||||
return entry[0]
|
return entry[0]
|
||||||
value = _fetch_seasonal_remote(lat, lon)
|
# Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
|
||||||
ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S
|
value = _fetch_seasonal_remote(lat, lon)
|
||||||
|
ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||||||
|
with _CLIMATE_LOCK:
|
||||||
_evict_one_if_full(_CLIMATE_CACHE)
|
_evict_one_if_full(_CLIMATE_CACHE)
|
||||||
_CLIMATE_CACHE[key] = (value, _now() + ttl)
|
_CLIMATE_CACHE[key] = (value, _now() + ttl)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
"""Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа.
|
"""Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа.
|
||||||
|
|
||||||
Перенесено из `app.api.v1.parcels._fetch_air_quality_sync` без изменения формата
|
Использует `current=...` (bucket текущего часа), а не `hourly[0]` (=00:00 forecast-дня) —
|
||||||
возвращаемого dict (фронт зависит). Любое исключение → logger.warning + None
|
docstring/UX обещают «качество воздуха сейчас» (#1377). Формат возвращаемого dict
|
||||||
(caller обернёт None в negative-cache на короткий TTL).
|
(ключи pm2_5/pm10/no2/ts/source) сохранён — фронт зависит. Любое исключение →
|
||||||
|
logger.warning + None (caller обернёт None в negative-cache на короткий TTL).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=_AIR_TIMEOUT_S) as c:
|
with httpx.Client(timeout=_AIR_TIMEOUT_S) as c:
|
||||||
|
|
@ -301,20 +310,21 @@ def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
params={
|
params={
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
"longitude": lon,
|
"longitude": lon,
|
||||||
"hourly": "pm2_5,pm10,nitrogen_dioxide",
|
# `current` отдаёт bucket текущего часа (а не hourly[0]=00:00) —
|
||||||
"forecast_days": 1,
|
# docstring/UX обещают «качество воздуха сейчас». См. #1377.
|
||||||
|
"current": "pm2_5,pm10,nitrogen_dioxide",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
hourly = data.get("hourly", {})
|
current = data.get("current", {})
|
||||||
if not hourly.get("time"):
|
if not current.get("time"):
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None,
|
"pm2_5": current.get("pm2_5"),
|
||||||
"pm10": hourly["pm10"][0] if hourly.get("pm10") else None,
|
"pm10": current.get("pm10"),
|
||||||
"no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None,
|
"no2": current.get("nitrogen_dioxide"),
|
||||||
"ts": hourly["time"][0],
|
"ts": current["time"],
|
||||||
"source": "open-meteo",
|
"source": "open-meteo",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -335,8 +345,10 @@ def get_air_quality_cached(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
entry = _AIR_CACHE.get(key)
|
entry = _AIR_CACHE.get(key)
|
||||||
if entry is not None and entry[1] > now:
|
if entry is not None and entry[1] > now:
|
||||||
return entry[0]
|
return entry[0]
|
||||||
value = _fetch_air_quality_remote(lat, lon)
|
# Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
|
||||||
ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S
|
value = _fetch_air_quality_remote(lat, lon)
|
||||||
|
ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||||||
|
with _AIR_LOCK:
|
||||||
_evict_one_if_full(_AIR_CACHE)
|
_evict_one_if_full(_AIR_CACHE)
|
||||||
_AIR_CACHE[key] = (value, _now() + ttl)
|
_AIR_CACHE[key] = (value, _now() + ttl)
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,11 @@ def _save_quarter(db: Session, payload: dict, cad_num: str) -> int:
|
||||||
-- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58).
|
-- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58).
|
||||||
-- NSPD может вернуть одноконтурный Polygon → ST_Multi coerce'ит в MultiPolygon,
|
-- NSPD может вернуть одноконтурный Polygon → ST_Multi coerce'ит в MultiPolygon,
|
||||||
-- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
|
-- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
|
||||||
-- 3857→4326 transform как в upsert_quarter_geom_from_feature (Mercator).
|
-- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84
|
||||||
ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)),
|
-- (EPSG:4326, degrees) для всех area_type — только ST_SetSRID.
|
||||||
|
-- ST_Transform из 3857 трактовал бы градусы как метры Mercator →
|
||||||
|
-- коллапс к Null Island (issue #1336).
|
||||||
|
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)),
|
||||||
CAST(:props AS jsonb), NOW())
|
CAST(:props AS jsonb), NOW())
|
||||||
ON CONFLICT (cad_number) DO UPDATE
|
ON CONFLICT (cad_number) DO UPDATE
|
||||||
SET geom = EXCLUDED.geom,
|
SET geom = EXCLUDED.geom,
|
||||||
|
|
@ -200,7 +203,11 @@ def _save_building(db: Session, payload: dict, cad_num: str) -> int:
|
||||||
readable_address, area, floors, raw_props, source
|
readable_address, area, floors, raw_props, source
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:cad, :qcad,
|
:cad, :qcad,
|
||||||
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326),
|
-- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84
|
||||||
|
-- (EPSG:4326, degrees) для всех area_type — только ST_SetSRID.
|
||||||
|
-- ST_Transform из 3857 трактовал бы градусы как метры Mercator →
|
||||||
|
-- коллапс к Null Island (issue #1336).
|
||||||
|
ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326),
|
||||||
:purpose, :name, :addr, :area, :floors, CAST(:props AS jsonb), :source
|
:purpose, :name, :addr, :area, :floors, CAST(:props AS jsonb), :source
|
||||||
)
|
)
|
||||||
ON CONFLICT (cad_num) DO UPDATE
|
ON CONFLICT (cad_num) DO UPDATE
|
||||||
|
|
@ -237,8 +244,9 @@ def _save_parcel(db: Session, payload: dict, cad_num: str) -> int:
|
||||||
|
|
||||||
NB: rosreestr2coord для area_type=1 (parcel) отдаёт GeoJSON уже в WGS84
|
NB: rosreestr2coord для area_type=1 (parcel) отдаёт GeoJSON уже в WGS84
|
||||||
(EPSG:4326, degrees). Просто ST_SetSRID — без ST_Transform, иначе геометрия
|
(EPSG:4326, degrees). Просто ST_SetSRID — без ST_Transform, иначе геометрия
|
||||||
сжимается до точки в районе (0,0) на острове Null. Quarters/buildings —
|
сжимается до точки в районе (0,0) на острове Null. Quarters/buildings идут
|
||||||
другой path, у них Mercator (3857) и нужен ST_Transform.
|
через тот же rosreestr2coord path и тоже WGS84 — им ST_Transform тоже НЕ нужен
|
||||||
|
(issue #1336: раньше ошибочно трансформировались из 3857 → Null Island).
|
||||||
"""
|
"""
|
||||||
feats = (payload.get("data") or {}).get("features") or []
|
feats = (payload.get("data") or {}).get("features") or []
|
||||||
if not feats:
|
if not feats:
|
||||||
|
|
|
||||||
|
|
@ -326,7 +326,10 @@ def sync_objective_group(
|
||||||
use_dkp=params.get("use_dkp", False),
|
use_dkp=params.get("use_dkp", False),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
reports_ok += 1
|
# NB: reports_ok инкрементируем ТОЛЬКО ПОСЛЕ успешного
|
||||||
|
# parse+commit (зеркально lots_pf / issue #1220, #1369).
|
||||||
|
# Иначе сбой parse_corp_sum оставлял бы reports_failed==0 →
|
||||||
|
# _finish_run помечал бы run status='done' → silent failure.
|
||||||
try:
|
try:
|
||||||
if kind == "corp_sum":
|
if kind == "corp_sum":
|
||||||
n = parser_mod.parse_corp_sum(
|
n = parser_mod.parse_corp_sum(
|
||||||
|
|
@ -341,8 +344,10 @@ def sync_objective_group(
|
||||||
{"n": n, "rid": raw_id},
|
{"n": n, "rid": raw_id},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
reports_ok += 1
|
||||||
except Exception as parse_err:
|
except Exception as parse_err:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
reports_failed += 1
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s",
|
"sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s",
|
||||||
section,
|
section,
|
||||||
|
|
|
||||||
|
|
@ -99,13 +99,13 @@ def _make_httpx_response(payload: dict[str, Any]) -> MagicMock:
|
||||||
|
|
||||||
|
|
||||||
def _make_air_response() -> dict[str, Any]:
|
def _make_air_response() -> dict[str, Any]:
|
||||||
"""Минимальный валидный JSON от Open-Meteo Air Quality API (24 часа forecast)."""
|
"""Минимальный валидный JSON от Open-Meteo Air Quality API (current bucket, #1377)."""
|
||||||
return {
|
return {
|
||||||
"hourly": {
|
"current": {
|
||||||
"time": ["2026-06-12T00:00", "2026-06-12T01:00"],
|
"time": "2026-06-12T14:00",
|
||||||
"pm2_5": [12.5, 13.0],
|
"pm2_5": 12.5,
|
||||||
"pm10": [25.0, 26.0],
|
"pm10": 25.0,
|
||||||
"nitrogen_dioxide": [15.0, 16.0],
|
"nitrogen_dioxide": 15.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,13 +187,17 @@ def test_save_quarter_sql_wraps_geom_in_st_multi() -> None:
|
||||||
assert "ST_Multi(" in sql, "SQL _save_quarter должен содержать ST_Multi для MultiPolygon schema"
|
assert "ST_Multi(" in sql, "SQL _save_quarter должен содержать ST_Multi для MultiPolygon schema"
|
||||||
|
|
||||||
|
|
||||||
def test_save_quarter_sql_keeps_3857_to_4326_transform() -> None:
|
def test_save_quarter_sql_no_transform_wgs84() -> None:
|
||||||
"""quarter GeoJSON в Mercator (3857) → ST_Transform к 4326 сохраняется (mirror bulk path)."""
|
"""quarter GeoJSON уже в WGS84 (rosreestr2coord) → ST_SetSRID(...,4326) БЕЗ ST_Transform
|
||||||
|
(#1336: ST_Transform из 3857 трактовал градусы как метры → коллапс к Null Island).
|
||||||
|
ST_Multi сохраняется — колонка cad_quarters_geom.geom = MultiPolygon."""
|
||||||
db, captured = _capturing_db()
|
db, captured = _capturing_db()
|
||||||
_save_quarter(db, _quarter_payload(), "66:41:0610029")
|
_save_quarter(db, _quarter_payload(), "66:41:0610029")
|
||||||
|
|
||||||
sql, _params = captured[0]
|
sql, _params = captured[0]
|
||||||
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql
|
executable = _strip_sql_comments(sql)
|
||||||
|
assert "ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326))" in executable
|
||||||
|
assert "ST_Transform" not in executable
|
||||||
|
|
||||||
|
|
||||||
def test_save_parcel_empty_features_returns_zero() -> None:
|
def test_save_parcel_empty_features_returns_zero() -> None:
|
||||||
|
|
@ -261,15 +265,18 @@ def test_save_building_derives_quarter_when_missing() -> None:
|
||||||
assert params["qcad"] == "66:41:0610029"
|
assert params["qcad"] == "66:41:0610029"
|
||||||
|
|
||||||
|
|
||||||
def test_save_building_geom_transform_no_st_multi() -> None:
|
def test_save_building_geom_no_transform_no_st_multi() -> None:
|
||||||
"""cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен,
|
"""cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен;
|
||||||
transform 3857→4326 сохраняется (mirror upsert_building)."""
|
геометрия уже WGS84 (rosreestr2coord) → ST_SetSRID(...,4326) БЕЗ ST_Transform
|
||||||
|
(#1336: ST_Transform из 3857 → Null Island)."""
|
||||||
db, captured = _capturing_db()
|
db, captured = _capturing_db()
|
||||||
_save_building(db, _building_payload(), "66:41:0610029:83")
|
_save_building(db, _building_payload(), "66:41:0610029:83")
|
||||||
|
|
||||||
sql, _params = captured[0]
|
sql, _params = captured[0]
|
||||||
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql
|
executable = _strip_sql_comments(sql)
|
||||||
assert "ST_Multi(" not in sql # полиморфная колонка — coerce не нужен
|
assert "ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)" in executable
|
||||||
|
assert "ST_Transform" not in executable
|
||||||
|
assert "ST_Multi(" not in executable # полиморфная колонка — coerce не нужен
|
||||||
|
|
||||||
|
|
||||||
def test_save_building_skips_feature_with_other_cad_num() -> None:
|
def test_save_building_skips_feature_with_other_cad_num() -> None:
|
||||||
|
|
@ -562,9 +569,9 @@ def test_soft_time_limit_exceeded_flushes_heartbeat(monkeypatch: Any) -> None:
|
||||||
for sql, params in captured
|
for sql, params in captured
|
||||||
if "heartbeat_at = NOW()" in sql and "targets_done" in sql
|
if "heartbeat_at = NOW()" in sql and "targets_done" in sql
|
||||||
]
|
]
|
||||||
assert heartbeat_updates, (
|
assert (
|
||||||
"SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
|
heartbeat_updates
|
||||||
)
|
), "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
|
||||||
|
|
||||||
|
|
||||||
def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed(
|
def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed(
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,19 @@ ALTER TABLE domrf_kn_infrastructure
|
||||||
|
|
||||||
-- Step 3: add new unique constraint with NULLS NOT DISTINCT (PG15+)
|
-- Step 3: add new unique constraint with NULLS NOT DISTINCT (PG15+)
|
||||||
-- so NULL poi_category / poi_address are treated as equal (not distinct)
|
-- so NULL poi_category / poi_address are treated as equal (not distinct)
|
||||||
ALTER TABLE domrf_kn_infrastructure
|
-- PostgreSQL has no ADD CONSTRAINT ... IF NOT EXISTS form, so guard via DO $$
|
||||||
ADD CONSTRAINT uq_infra_dedupe
|
-- block (idempotency for DR-restore / re-apply under ON_ERROR_STOP).
|
||||||
UNIQUE NULLS NOT DISTINCT (obj_id, poi_category, poi_name, poi_address);
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'uq_infra_dedupe'
|
||||||
|
AND conrelid = 'domrf_kn_infrastructure'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE domrf_kn_infrastructure
|
||||||
|
ADD CONSTRAINT uq_infra_dedupe
|
||||||
|
UNIQUE NULLS NOT DISTINCT (obj_id, poi_category, poi_name, poi_address);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS own_planned_project (
|
||||||
-- Тайминг: планируемый месяц выхода в продажу. Хранится как ПЕРВОЕ число месяца
|
-- Тайминг: планируемый месяц выхода в продажу. Хранится как ПЕРВОЕ число месяца
|
||||||
-- (YYYY-MM-01) — нормализуется на уровне сервиса/SQL; ось тайминга §25.3.
|
-- (YYYY-MM-01) — нормализуется на уровне сервиса/SQL; ось тайминга §25.3.
|
||||||
planned_release_month DATE,
|
planned_release_month DATE,
|
||||||
-- Цена ₽/м² (вилка). min/max независимы; оба ≥0 (guarded CHECK ниже).
|
-- Цена ₽/м² (вилка). Оба ≥0; min ≤ max когда обе границы заданы (guarded CHECK ниже).
|
||||||
price_min_per_m2 NUMERIC,
|
price_min_per_m2 NUMERIC,
|
||||||
price_max_per_m2 NUMERIC,
|
price_max_per_m2 NUMERIC,
|
||||||
-- Квартирография: доли по форматам {"studio":0.3,"1k":0.4,"2k":0.2,"3k":0.1}.
|
-- Квартирография: доли по форматам {"studio":0.3,"1k":0.4,"2k":0.2,"3k":0.1}.
|
||||||
|
|
@ -67,8 +67,8 @@ COMMENT ON COLUMN own_planned_project.obj_class IS
|
||||||
'Зеркалит class-вокабуляр forecasting SegmentSpec; матчинг в PR2 регистронезависимый.';
|
'Зеркалит class-вокабуляр forecasting SegmentSpec; матчинг в PR2 регистронезависимый.';
|
||||||
COMMENT ON COLUMN own_planned_project.planned_release_month IS
|
COMMENT ON COLUMN own_planned_project.planned_release_month IS
|
||||||
'Планируемый месяц выхода в продажу — ПЕРВОЕ число месяца (YYYY-MM-01). Ось тайминга §25.3.';
|
'Планируемый месяц выхода в продажу — ПЕРВОЕ число месяца (YYYY-MM-01). Ось тайминга §25.3.';
|
||||||
COMMENT ON COLUMN own_planned_project.price_min_per_m2 IS 'Нижняя граница цены, ₽/м² (≥0).';
|
COMMENT ON COLUMN own_planned_project.price_min_per_m2 IS 'Нижняя граница цены, ₽/м² (≥0; ≤ price_max_per_m2 когда обе заданы).';
|
||||||
COMMENT ON COLUMN own_planned_project.price_max_per_m2 IS 'Верхняя граница цены, ₽/м² (≥0).';
|
COMMENT ON COLUMN own_planned_project.price_max_per_m2 IS 'Верхняя граница цены, ₽/м² (≥0; ≥ price_min_per_m2 когда обе заданы).';
|
||||||
COMMENT ON COLUMN own_planned_project.unit_mix IS
|
COMMENT ON COLUMN own_planned_project.unit_mix IS
|
||||||
'Квартирография: JSONB долей по форматам {"studio":0.3,"1k":0.4,...}; каждая доля в [0,1].';
|
'Квартирография: JSONB долей по форматам {"studio":0.3,"1k":0.4,...}; каждая доля в [0,1].';
|
||||||
COMMENT ON COLUMN own_planned_project.geom IS
|
COMMENT ON COLUMN own_planned_project.geom IS
|
||||||
|
|
@ -90,6 +90,29 @@ BEGIN
|
||||||
END
|
END
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
-- ── 2b. CHECK на корректность вилки: min ≤ max (guarded — idempotent) ──────────
|
||||||
|
-- DB-уровневый backstop инварианта price-range (#1362). Pydantic-валидатор есть
|
||||||
|
-- только на OwnPlannedProjectCreate; PUT (OwnPlannedProjectUpdate) и partial-merge
|
||||||
|
-- в service-слое могут записать инвертированную вилку (min > max), которая молча
|
||||||
|
-- кормит §25.3 cannibalization бессмысленными ценовыми полосами. Этот CHECK ловит
|
||||||
|
-- инверсию независимо от пути записи (create / partial update / прямой SQL).
|
||||||
|
-- Срабатывает ТОЛЬКО когда обе границы заданы — частичная вилка (одна NULL) валидна.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'ck_own_planned_project_price_range'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE own_planned_project
|
||||||
|
ADD CONSTRAINT ck_own_planned_project_price_range
|
||||||
|
CHECK (
|
||||||
|
price_min_per_m2 IS NULL
|
||||||
|
OR price_max_per_m2 IS NULL
|
||||||
|
OR price_min_per_m2 <= price_max_per_m2
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
-- ── 3. CHECK на unit_mix: объект, все значения числовые в [0,1] (guarded) ──────
|
-- ── 3. CHECK на unit_mix: объект, все значения числовые в [0,1] (guarded) ──────
|
||||||
-- Разрешаем NULL (не задано). Если задан — обязан быть JSON-объектом, и каждое
|
-- Разрешаем NULL (не задано). Если задан — обязан быть JSON-объектом, и каждое
|
||||||
-- значение должно быть числом в [0,1] (доля).
|
-- значение должно быть числом в [0,1] (доля).
|
||||||
|
|
|
||||||
33
data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql
Normal file
33
data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- 158_pzz_zones_ekb_nulls_not_distinct.sql
|
||||||
|
-- #1361 — применить UNIQUE NULLS NOT DISTINCT (rosreestr_id) на СУЩЕСТВУЮЩЕМ проде.
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ. Правка inline-констрейнта в 85_pzz_zones_ekb.sql
|
||||||
|
-- (CREATE TABLE IF NOT EXISTS) НЕ долетает до прода: 85 уже в _schema_migrations
|
||||||
|
-- → на деплое пропускается, таблица не пересоздаётся, констрейнт остаётся старым
|
||||||
|
-- `UNIQUE (rosreestr_id)` (NULLS DISTINCT). Эта миграция меняет констрейнт на живой
|
||||||
|
-- таблице, чтобы фикс #1361 реально вступил в силу (правка 85 — для fresh-install).
|
||||||
|
--
|
||||||
|
-- БАГ #1361. rosreestr_id nullable (PKK6 при f=geojson иногда кладёт OBJECTID в
|
||||||
|
-- feature-level id, а не в properties → loader получает NULL). При обычном UNIQUE
|
||||||
|
-- NULL != NULL → ON CONFLICT (rosreestr_id) НЕ матчит существующие NULL-строки →
|
||||||
|
-- каждый повторный sync копит дубли зон. NULLS NOT DISTINCT (PG15+) делает NULL
|
||||||
|
-- сопоставимым → ON CONFLICT матчит → дубли не копятся.
|
||||||
|
--
|
||||||
|
-- БЕЗОПАСНОСТЬ. Проверено на проде 2026-06-15: pzz_zones_ekb ПУСТА (0 строк),
|
||||||
|
-- текущий констрейнт = pzz_zones_ekb_rosreestr_id_key UNIQUE (rosreestr_id). На пустой
|
||||||
|
-- таблице DROP+ADD не теряет данных и не может упасть на дублях. Прецедент NULLS NOT
|
||||||
|
-- DISTINCT в репо: м.110 (uq_infra_dedupe), м.125, м.140. Prod = PG16.
|
||||||
|
-- Idempotent: DROP CONSTRAINT IF EXISTS + повторный ADD под gate _schema_migrations
|
||||||
|
-- (ровно один раз по NN-порядку).
|
||||||
|
-- Apply after: 157_analyze_hotpath_indexes.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE pzz_zones_ekb
|
||||||
|
DROP CONSTRAINT IF EXISTS pzz_zones_ekb_rosreestr_id_key;
|
||||||
|
|
||||||
|
ALTER TABLE pzz_zones_ekb
|
||||||
|
ADD CONSTRAINT pzz_zones_ekb_rosreestr_id_key
|
||||||
|
UNIQUE NULLS NOT DISTINCT (rosreestr_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -12,6 +12,12 @@ ALTER TABLE kn_scrape_runs
|
||||||
ADD COLUMN IF NOT EXISTS params JSONB,
|
ADD COLUMN IF NOT EXISTS params JSONB,
|
||||||
ADD COLUMN IF NOT EXISTS resumed_from_run_id BIGINT REFERENCES kn_scrape_runs(run_id);
|
ADD COLUMN IF NOT EXISTS resumed_from_run_id BIGINT REFERENCES kn_scrape_runs(run_id);
|
||||||
|
|
||||||
|
-- Reaper (admin_scrape.py) фильтрует по выражению COALESCE(heartbeat_at, started_at)
|
||||||
|
-- среди status='running'. B-tree по голому heartbeat_at не обслуживает range по
|
||||||
|
-- этому выражению → индекс был мёртв. Индексируем то же выражение и тот же
|
||||||
|
-- статус-предикат, что и в запросе. DROP IF EXISTS — определение индекса
|
||||||
|
-- меняется, а CREATE INDEX IF NOT EXISTS сверяет только имя (см. sql.md).
|
||||||
|
DROP INDEX IF EXISTS idx_kn_scrape_runs_running_heartbeat;
|
||||||
CREATE INDEX IF NOT EXISTS idx_kn_scrape_runs_running_heartbeat
|
CREATE INDEX IF NOT EXISTS idx_kn_scrape_runs_running_heartbeat
|
||||||
ON kn_scrape_runs (heartbeat_at)
|
ON kn_scrape_runs (COALESCE(heartbeat_at, started_at))
|
||||||
WHERE status IN ('running', 'resuming');
|
WHERE status = 'running';
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
-- Загружается через Celery task tasks.pzz_sync.sync_pzz_zones_ekb
|
-- Загружается через Celery task tasks.pzz_sync.sync_pzz_zones_ekb
|
||||||
-- (ad-hoc: POST /api/v1/admin/scrape/pzz-sync).
|
-- (ad-hoc: POST /api/v1/admin/scrape/pzz-sync).
|
||||||
--
|
--
|
||||||
-- Зависимости: PostGIS (уже установлен).
|
-- Зависимости: PostGIS (уже установлен); PostgreSQL 15+ для UNIQUE NULLS NOT
|
||||||
|
-- DISTINCT на rosreestr_id (prod — PG16, postgis/postgis:16-3.4).
|
||||||
-- Применять: psql gendesign < data/sql/85_pzz_zones_ekb.sql
|
-- Применять: psql gendesign < data/sql/85_pzz_zones_ekb.sql
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
@ -19,7 +20,12 @@ CREATE TABLE IF NOT EXISTS pzz_zones_ekb (
|
||||||
raw_props JSONB DEFAULT '{}'::jsonb,
|
raw_props JSONB DEFAULT '{}'::jsonb,
|
||||||
geom GEOMETRY(MultiPolygon, 4326) NOT NULL,
|
geom GEOMETRY(MultiPolygon, 4326) NOT NULL,
|
||||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
UNIQUE(rosreestr_id)
|
-- NULLS NOT DISTINCT (PG15+): rosreestr_id nullable (PKK6 при f=geojson иногда
|
||||||
|
-- кладёт OBJECTID в feature-level id, а не в properties → loader получает NULL).
|
||||||
|
-- Без этого NULL != NULL в обычном UNIQUE → ON CONFLICT (rosreestr_id) НЕ матчит
|
||||||
|
-- существующие NULL-строки → каждый повторный sync копит дубли зон (#1361).
|
||||||
|
-- Прецедент в репо: м.110 (uq_infra_dedupe), м.125, м.140.
|
||||||
|
UNIQUE NULLS NOT DISTINCT (rosreestr_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS pzz_zones_ekb_geom_idx ON pzz_zones_ekb USING GIST(geom);
|
CREATE INDEX IF NOT EXISTS pzz_zones_ekb_geom_idx ON pzz_zones_ekb USING GIST(geom);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
"""Reads JSON-encoded string from stdin (the kind MCP returns when wrapping a string),
|
"""Reads a JSON value from stdin (the kind MCP returns when wrapping data),
|
||||||
and writes the decoded value as UTF-8 to the given file."""
|
and writes the decoded value as UTF-8 to the given file.
|
||||||
|
|
||||||
|
If the JSON decodes to a plain string it is written verbatim; otherwise
|
||||||
|
(list/dict/number/etc.) it is re-serialised with json.dumps so f.write
|
||||||
|
always receives a str."""
|
||||||
import json, sys, os
|
import json, sys, os
|
||||||
out_path = sys.argv[1]
|
out_path = sys.argv[1]
|
||||||
raw = sys.stdin.read()
|
raw = sys.stdin.read()
|
||||||
# raw is a JSON string like "[{\"name\":\"...\"}]" — already JSON-quoted
|
# raw is JSON like "[{\"name\":\"...\"}]" or a JSON-quoted string — already JSON-encoded
|
||||||
decoded = json.loads(raw)
|
decoded = json.loads(raw)
|
||||||
|
text = decoded if isinstance(decoded, str) else json.dumps(decoded, ensure_ascii=False)
|
||||||
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
|
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
|
||||||
with open(out_path, 'w', encoding='utf-8') as f:
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(decoded)
|
f.write(text)
|
||||||
print(f'wrote {out_path} ({len(decoded)} chars)')
|
print(f'wrote {out_path} ({len(text)} chars)')
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,36 @@ export default function ScrapeAllAdminPage() {
|
||||||
"all" | "kn" | "nspd" | "objective" | "nspd_geo"
|
"all" | "kn" | "nspd" | "objective" | "nspd_geo"
|
||||||
>("all");
|
>("all");
|
||||||
const [logsRunId, setLogsRunId] = useState<number | "">("");
|
const [logsRunId, setLogsRunId] = useState<number | "">("");
|
||||||
|
// scraper_type строки, по которой открыты логи: run_id не уникален между
|
||||||
|
// скраперами (независимые BIGSERIAL PK), поэтому фильтр логов обязан быть
|
||||||
|
// scoped на конкретный скрапер, иначе смешиваются прогоны с одинаковым run_id.
|
||||||
|
const [logsScraperType, setLogsScraperType] = useState<
|
||||||
|
"kn" | "nspd" | "nspd_geo" | ""
|
||||||
|
>("");
|
||||||
|
|
||||||
|
// Открыть логи конкретной строки: фиксируем и run_id, и scraper_type.
|
||||||
|
function openLogsFor(row: UnifiedRunRow) {
|
||||||
|
if (
|
||||||
|
row.scraper_type === "kn" ||
|
||||||
|
row.scraper_type === "nspd" ||
|
||||||
|
row.scraper_type === "nspd_geo"
|
||||||
|
) {
|
||||||
|
setLogsRunId(row.run_id);
|
||||||
|
setLogsScraperType(row.scraper_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс фильтра логов (кнопка «сбросить» и при смене таба).
|
||||||
|
function resetLogsFilter() {
|
||||||
|
setLogsRunId("");
|
||||||
|
setLogsScraperType("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Смена таба не должна тянуть стейл-фильтр логов чужого прогона.
|
||||||
|
function changeFilter(t: "all" | "kn" | "nspd" | "objective" | "nspd_geo") {
|
||||||
|
setFilter(t);
|
||||||
|
resetLogsFilter();
|
||||||
|
}
|
||||||
|
|
||||||
const runs = useQuery({
|
const runs = useQuery({
|
||||||
queryKey: ["unified-runs", filter],
|
queryKey: ["unified-runs", filter],
|
||||||
|
|
@ -83,11 +113,17 @@ export default function ScrapeAllAdminPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs = useQuery({
|
const logs = useQuery({
|
||||||
queryKey: ["unified-logs", filter, logsRunId],
|
queryKey: ["unified-logs", filter, logsScraperType, logsRunId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ limit: "100" });
|
const params = new URLSearchParams({ limit: "100" });
|
||||||
if (filter !== "all") params.set("scraper_type", filter);
|
// Когда открыт конкретный прогон — scope-им логи на его scraper_type
|
||||||
if (logsRunId !== "") params.set("run_id", String(logsRunId));
|
// (run_id не уникален между скраперами). Иначе используем таб-фильтр.
|
||||||
|
if (logsRunId !== "" && logsScraperType !== "") {
|
||||||
|
params.set("scraper_type", logsScraperType);
|
||||||
|
params.set("run_id", String(logsRunId));
|
||||||
|
} else if (filter !== "all") {
|
||||||
|
params.set("scraper_type", filter);
|
||||||
|
}
|
||||||
return apiFetch<UnifiedLogRow[]>(
|
return apiFetch<UnifiedLogRow[]>(
|
||||||
`/api/v1/admin/scrape/all/logs?${params}`,
|
`/api/v1/admin/scrape/all/logs?${params}`,
|
||||||
);
|
);
|
||||||
|
|
@ -95,14 +131,19 @@ export default function ScrapeAllAdminPage() {
|
||||||
refetchInterval: 8000,
|
refetchInterval: 8000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Сводка по statusам
|
// Сводка по statusам в показанной выборке (top-30, НЕ глобальные тоталы).
|
||||||
|
// v_scrape_runs_unified отдаёт и статусы вне 4 плиток (nspd skipped,
|
||||||
|
// nspd_geo paused/queued) — собираем их в "other", чтобы сумма плиток
|
||||||
|
// совпадала с числом строк, а не молча теряла прогоны.
|
||||||
const summary = (() => {
|
const summary = (() => {
|
||||||
const s = { running: 0, done: 0, failed: 0, zombie: 0 } as Record<
|
const s = { running: 0, done: 0, failed: 0, zombie: 0, other: 0 } as Record<
|
||||||
string,
|
string,
|
||||||
number
|
number
|
||||||
>;
|
>;
|
||||||
|
const known = new Set(["running", "done", "failed", "zombie"]);
|
||||||
runs.data?.forEach((r) => {
|
runs.data?.forEach((r) => {
|
||||||
s[r.status] = (s[r.status] ?? 0) + 1;
|
const key = known.has(r.status) ? r.status : "other";
|
||||||
|
s[key] = (s[key] ?? 0) + 1;
|
||||||
});
|
});
|
||||||
return s;
|
return s;
|
||||||
})();
|
})();
|
||||||
|
|
@ -137,7 +178,7 @@ export default function ScrapeAllAdminPage() {
|
||||||
...cardStyle,
|
...cardStyle,
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(4, 1fr)",
|
gridTemplateColumns: "repeat(5, 1fr)",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -149,6 +190,7 @@ export default function ScrapeAllAdminPage() {
|
||||||
<Stat label="✅ успешно" value={summary.done ?? 0} color="#16a34a" />
|
<Stat label="✅ успешно" value={summary.done ?? 0} color="#16a34a" />
|
||||||
<Stat label="❌ failed" value={summary.failed ?? 0} color="#b3261e" />
|
<Stat label="❌ failed" value={summary.failed ?? 0} color="#b3261e" />
|
||||||
<Stat label="🧟 zombie" value={summary.zombie ?? 0} color="#9333ea" />
|
<Stat label="🧟 zombie" value={summary.zombie ?? 0} color="#9333ea" />
|
||||||
|
<Stat label="• прочие" value={summary.other ?? 0} color="#6b7280" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Фильтр + табы */}
|
{/* Фильтр + табы */}
|
||||||
|
|
@ -160,7 +202,7 @@ export default function ScrapeAllAdminPage() {
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter(t)}
|
onClick={() => changeFilter(t)}
|
||||||
style={{
|
style={{
|
||||||
...filterTab,
|
...filterTab,
|
||||||
background: filter === t ? "#1d4ed8" : "#f3f4f6",
|
background: filter === t ? "#1d4ed8" : "#f3f4f6",
|
||||||
|
|
@ -201,10 +243,13 @@ export default function ScrapeAllAdminPage() {
|
||||||
const heartbeatLag = r.heartbeat_at
|
const heartbeatLag = r.heartbeat_at
|
||||||
? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000
|
? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000
|
||||||
: null;
|
: null;
|
||||||
|
// Порог выровнен с backend zombie-детектором (10 мин без
|
||||||
|
// heartbeat = точно мёртв, admin_scrape.py). Лаг 5-9 мин при
|
||||||
|
// долгом батче — нормальная живая работа, не "stale".
|
||||||
const isStale =
|
const isStale =
|
||||||
r.status === "running" &&
|
r.status === "running" &&
|
||||||
heartbeatLag !== null &&
|
heartbeatLag !== null &&
|
||||||
heartbeatLag > 300;
|
heartbeatLag > 600;
|
||||||
const badge = SCRAPER_BADGE[r.scraper_type];
|
const badge = SCRAPER_BADGE[r.scraper_type];
|
||||||
const statusColor = STATUS_COLOR[r.status] ?? "#6b7280";
|
const statusColor = STATUS_COLOR[r.status] ?? "#6b7280";
|
||||||
return (
|
return (
|
||||||
|
|
@ -292,7 +337,7 @@ export default function ScrapeAllAdminPage() {
|
||||||
r.scraper_type === "nspd_geo") && (
|
r.scraper_type === "nspd_geo") && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLogsRunId(r.run_id)}
|
onClick={() => openLogsFor(r)}
|
||||||
style={{
|
style={{
|
||||||
...secondaryBtn,
|
...secondaryBtn,
|
||||||
padding: "4px 10px",
|
padding: "4px 10px",
|
||||||
|
|
@ -342,10 +387,11 @@ export default function ScrapeAllAdminPage() {
|
||||||
📋 Логи (top 100){" "}
|
📋 Логи (top 100){" "}
|
||||||
{logsRunId !== "" && (
|
{logsRunId !== "" && (
|
||||||
<span style={{ fontSize: 13, fontWeight: 400, color: "#5b6066" }}>
|
<span style={{ fontSize: 13, fontWeight: 400, color: "#5b6066" }}>
|
||||||
· фильтр run_id={logsRunId}{" "}
|
· фильтр {logsScraperType && `${logsScraperType} `}run_id=
|
||||||
|
{logsRunId}{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLogsRunId("")}
|
onClick={resetLogsFilter}
|
||||||
style={{
|
style={{
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
@ -771,9 +771,8 @@ export default function CadastreScrapeAdminPage() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{jobs.data?.map((job) => (
|
{jobs.data?.map((job) => (
|
||||||
<>
|
<Fragment key={job.job_id}>
|
||||||
<tr
|
<tr
|
||||||
key={job.job_id}
|
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid #f3f4f6",
|
borderTop: "1px solid #f3f4f6",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
|
@ -859,7 +858,7 @@ export default function CadastreScrapeAdminPage() {
|
||||||
? expandedJob.data
|
? expandedJob.data
|
||||||
: undefined,
|
: undefined,
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{(jobs.data?.length ?? 0) === 0 && (
|
{(jobs.data?.length ?? 0) === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ export default function ObjectDrillInPage() {
|
||||||
label="Нежилые"
|
label="Нежилые"
|
||||||
value={nonliv?.total?.toString() ?? "—"}
|
value={nonliv?.total?.toString() ?? "—"}
|
||||||
hint={
|
hint={
|
||||||
nonliv?.perc != null
|
nonliv?.realised != null
|
||||||
? `${nonliv.realised} продано (${nonliv.perc}%)`
|
? `${nonliv.realised} продано (${nonliv.perc}%)`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -175,7 +175,7 @@ export default function ObjectDrillInPage() {
|
||||||
label="Машино-места"
|
label="Машино-места"
|
||||||
value={parking?.total?.toString() ?? "—"}
|
value={parking?.total?.toString() ?? "—"}
|
||||||
hint={
|
hint={
|
||||||
parking?.perc != null
|
parking?.realised != null
|
||||||
? `${parking.realised} продано (${parking.perc}%)`
|
? `${parking.realised} продано (${parking.perc}%)`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ export default function PrinzipPage() {
|
||||||
|
|
||||||
const d = detail.data;
|
const d = detail.data;
|
||||||
|
|
||||||
|
const areaGap =
|
||||||
|
d?.avg_area_sqm != null ? Math.round(49 - d.avg_area_sqm) : null;
|
||||||
|
const areaDeltaValue =
|
||||||
|
areaGap == null
|
||||||
|
? "vs рынок 49 м²"
|
||||||
|
: `vs рынок 49 м² · разрыв ${
|
||||||
|
areaGap > 0 ? `-${areaGap}` : areaGap < 0 ? `+${-areaGap}` : "0"
|
||||||
|
} м²`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
|
@ -68,7 +77,7 @@ export default function PrinzipPage() {
|
||||||
value={d?.avg_area_sqm?.toFixed(1) ?? "—"}
|
value={d?.avg_area_sqm?.toFixed(1) ?? "—"}
|
||||||
unit="м²"
|
unit="м²"
|
||||||
delta={{
|
delta={{
|
||||||
value: `vs рынок 49 м² · разрыв -${d?.avg_area_sqm ? Math.round(49 - d.avg_area_sqm) : 0} м²`,
|
value: areaDeltaValue,
|
||||||
positive: false,
|
positive: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,20 @@ export default function ConceptPage() {
|
||||||
const cadastreGeom = useCadastreGeom();
|
const cadastreGeom = useCadastreGeom();
|
||||||
const concept = useCreateConcept();
|
const concept = useCreateConcept();
|
||||||
|
|
||||||
|
// Any change to the parcel boundary invalidates a previous concept run:
|
||||||
|
// its variants/ТЭП/financials were computed for the OLD parcel, so showing
|
||||||
|
// them against the new outline would be inconsistent.
|
||||||
|
function handlePolygonChange(next: Polygon | null) {
|
||||||
|
setPolygon(next);
|
||||||
|
if (concept.data || concept.isError) {
|
||||||
|
concept.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCadSubmit(cad: string) {
|
function handleCadSubmit(cad: string) {
|
||||||
cadastreGeom.mutate(cad, {
|
cadastreGeom.mutate(cad, {
|
||||||
onSuccess: (geom) => {
|
onSuccess: (geom) => {
|
||||||
setPolygon(geom);
|
handlePolygonChange(geom);
|
||||||
// Fresh boundary invalidates a previous concept run.
|
|
||||||
concept.reset();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +210,7 @@ export default function ConceptPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConceptDrawMap polygon={polygon} onChange={setPolygon} />
|
<ConceptDrawMap polygon={polygon} onChange={handlePolygonChange} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ const SOURCES: DataSource[] = [
|
||||||
"Цены предложения и скорость продаж по жилым комплексам (sale-graph, velocity).",
|
"Цены предложения и скорость продаж по жилым комплексам (sale-graph, velocity).",
|
||||||
license: "Коммерческий доступ по договору (платный тариф)",
|
license: "Коммерческий доступ по договору (платный тариф)",
|
||||||
attribution: "Данные о ценах предложения предоставлены сервисом «Объектив»",
|
attribution: "Данные о ценах предложения предоставлены сервисом «Объектив»",
|
||||||
href: "https://objced.ru",
|
href: "https://objctv.ru",
|
||||||
hrefLabel: "objced.ru",
|
hrefLabel: "objctv.ru",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Open-Meteo",
|
name: "Open-Meteo",
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,21 @@ function isTabId(v: string | null | undefined): v is TabId {
|
||||||
|
|
||||||
type KpiColor = "neutral" | "amber" | "green" | "red" | "blue";
|
type KpiColor = "neutral" | "amber" | "green" | "red" | "blue";
|
||||||
|
|
||||||
|
// Пороги синхронизированы с backend market_trend.label (parcels.py:2250-2257):
|
||||||
|
// >8 «Сильный рост» (green), >0 «Умеренный рост» (blue),
|
||||||
|
// >-5 «Стагнация» (amber), иначе «Падение» (red). #1415.
|
||||||
function trendColor(pct: number): KpiColor {
|
function trendColor(pct: number): KpiColor {
|
||||||
if (pct > 5) return "green";
|
if (pct > 8) return "green";
|
||||||
if (pct > 0) return "blue";
|
if (pct > 0) return "blue";
|
||||||
if (pct > -5) return "amber";
|
if (pct > -5) return "amber";
|
||||||
return "red";
|
return "red";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пороги синхронизированы с backend noise.level (parcels.py:1809-1814):
|
||||||
|
// <50 «тихо» (green), <65 «умеренный» (amber), иначе «шумно» (red). #1416.
|
||||||
function noiseColor(db: number): KpiColor {
|
function noiseColor(db: number): KpiColor {
|
||||||
if (db < 45) return "green";
|
if (db < 50) return "green";
|
||||||
if (db < 60) return "amber";
|
if (db < 65) return "amber";
|
||||||
return "red";
|
return "red";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,8 +200,12 @@ function SiteFinderContent() {
|
||||||
: { weights },
|
: { weights },
|
||||||
});
|
});
|
||||||
// mutate is stable from useMutation — safe to omit from deps.
|
// mutate is stable from useMutation — safe to omit from deps.
|
||||||
|
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
||||||
|
// переходит undefined→значение) ещё «висящий» pendingWeightsChange всё-таки
|
||||||
|
// применился, а не терялся (#1418). При повторном analyze того же участка
|
||||||
|
// cad_num не меняется → лишнего перезапуска/петли нет.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedWeightsChange]);
|
}, [debouncedWeightsChange, data?.cad_num]);
|
||||||
|
|
||||||
function handleAnalyze(cadNum: string) {
|
function handleAnalyze(cadNum: string) {
|
||||||
setIsochrones(undefined);
|
setIsochrones(undefined);
|
||||||
|
|
@ -222,11 +231,12 @@ function SiteFinderContent() {
|
||||||
) {
|
) {
|
||||||
setCurrentWeights(weights);
|
setCurrentWeights(weights);
|
||||||
setActiveProfileId(profileId);
|
setActiveProfileId(profileId);
|
||||||
// Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2).
|
// Enqueue a debounced re-analyze (#201 Phase 2). The actual mutate fires
|
||||||
// The actual mutate fires after 300ms of silence via debouncedWeightsChange.
|
// after 300ms of silence via debouncedWeightsChange. Не гардим по
|
||||||
if (data?.cad_num) {
|
// data?.cad_num: если веса меняют пока первичный analyze ещё в полёте,
|
||||||
setPendingWeightsChange({ weights, profileId });
|
// pending всё равно ставится и применяется, когда data придёт — иначе
|
||||||
}
|
// изменение молча теряется и score не соответствует ползункам (#1418).
|
||||||
|
setPendingWeightsChange({ weights, profileId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive KPI values from data
|
// Derive KPI values from data
|
||||||
|
|
@ -242,9 +252,15 @@ function SiteFinderContent() {
|
||||||
: "neutral";
|
: "neutral";
|
||||||
|
|
||||||
const districtValue = data?.district ? data.district.district_name : "—";
|
const districtValue = data?.district ? data.district.district_name : "—";
|
||||||
|
// Расстояние до ЦЕНТРА ЕКБ — location.distance_to_center_km (хаверсин до EKB_CENTER,
|
||||||
|
// уже в км). НЕ district.dist_to_center: то расстояние до полигона ближайшего района
|
||||||
|
// (≈0 внутри района), а не до центра города (#1414).
|
||||||
|
const distToCenterKm = data?.location?.distance_to_center_km;
|
||||||
const districtLabel = data?.district
|
const districtLabel = data?.district
|
||||||
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` +
|
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` +
|
||||||
` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра`
|
(distToCenterKm != null
|
||||||
|
? ` · ${distToCenterKm.toFixed(1)} км от центра`
|
||||||
|
: "")
|
||||||
: "Район";
|
: "Район";
|
||||||
|
|
||||||
const trendPct = data?.market_trend?.delta_6m_pct;
|
const trendPct = data?.market_trend?.delta_6m_pct;
|
||||||
|
|
@ -261,8 +277,10 @@ function SiteFinderContent() {
|
||||||
const noiseDb = data?.noise?.estimated_db;
|
const noiseDb = data?.noise?.estimated_db;
|
||||||
const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—";
|
const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—";
|
||||||
const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум";
|
const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум";
|
||||||
|
// Цвет считаем от того же округлённого значения, что показано в KPI,
|
||||||
|
// чтобы число и окраска не расходились у границы порога (#1417).
|
||||||
const noiseKpiColor: KpiColor =
|
const noiseKpiColor: KpiColor =
|
||||||
noiseDb != null ? noiseColor(noiseDb) : "neutral";
|
noiseDb != null ? noiseColor(Math.round(noiseDb)) : "neutral";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
|
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,16 @@ export default function ComparePage() {
|
||||||
if (
|
if (
|
||||||
existing &&
|
existing &&
|
||||||
existing.status === col.status &&
|
existing.status === col.status &&
|
||||||
existing.score === col.score &&
|
existing.errorMsg === col.errorMsg &&
|
||||||
|
existing.districtName === col.districtName &&
|
||||||
existing.verdictLabel === col.verdictLabel &&
|
existing.verdictLabel === col.verdictLabel &&
|
||||||
|
existing.score === col.score &&
|
||||||
|
existing.scoreLabel === col.scoreLabel &&
|
||||||
existing.velocityScore === col.velocityScore &&
|
existing.velocityScore === col.velocityScore &&
|
||||||
|
existing.velocityDataAvailable === col.velocityDataAvailable &&
|
||||||
|
existing.medianPricePerM2 === col.medianPricePerM2 &&
|
||||||
existing.pipeline24mo === col.pipeline24mo &&
|
existing.pipeline24mo === col.pipeline24mo &&
|
||||||
|
existing.confidenceLabel === col.confidenceLabel &&
|
||||||
existing.confidenceValue === col.confidenceValue
|
existing.confidenceValue === col.confidenceValue
|
||||||
) {
|
) {
|
||||||
return prev;
|
return prev;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import { CadInput } from "@/components/site-finder/CadInput";
|
import { CadInput } from "@/components/site-finder/CadInput";
|
||||||
import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
|
import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
|
||||||
|
|
@ -57,15 +57,38 @@ interface FilterBarBridgeProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
|
function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
|
||||||
const { data: allInBbox } = useParcelsBboxQuery(bbox, {});
|
// useParcelsBboxQuery has no keepPreviousData (unlike useParcelAnalyzeQuery),
|
||||||
const { data: filtered } = useParcelsBboxQuery(bbox, filters);
|
// and its queryKey carries the bbox — so on every pan/zoom both `data` go
|
||||||
|
// undefined mid-fetch. We hold the last fully-resolved pair so the counter
|
||||||
|
// doesn't flash "0 / 0" while fetching (#1419), and so totalCount/matchCount
|
||||||
|
// are always taken from the *same* committed frame — never a new bbox's
|
||||||
|
// matchCount over an old bbox's totalCount, which could read matchCount >
|
||||||
|
// totalCount and break the X ⊆ Y invariant (#1420).
|
||||||
|
const allQuery = useParcelsBboxQuery(bbox, {});
|
||||||
|
const filteredQuery = useParcelsBboxQuery(bbox, filters);
|
||||||
|
|
||||||
|
const lastCounts = useRef<{ totalCount: number; matchCount: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only commit a new pair once BOTH queries have data for the current render;
|
||||||
|
// until then keep the previous pair (placeholderData: keepPreviousData
|
||||||
|
// equivalent, scoped to this bridge).
|
||||||
|
if (allQuery.data != null && filteredQuery.data != null) {
|
||||||
|
lastCounts.current = {
|
||||||
|
totalCount: allQuery.data.length,
|
||||||
|
matchCount: filteredQuery.data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = lastCounts.current ?? { totalCount: 0, matchCount: 0 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapFilterBar
|
<MapFilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
totalCount={allInBbox?.length ?? 0}
|
totalCount={counts.totalCount}
|
||||||
matchCount={filtered?.length ?? 0}
|
matchCount={counts.matchCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -12,19 +12,12 @@ import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||||||
import { EstimateResult } from "@/components/trade-in/EstimateResult";
|
import { EstimateResult } from "@/components/trade-in/EstimateResult";
|
||||||
import { EstimateProgress } from "@/components/trade-in/EstimateProgress";
|
import { EstimateProgress } from "@/components/trade-in/EstimateProgress";
|
||||||
|
|
||||||
// ── URL persistence helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function useEstimateId() {
|
|
||||||
// SSR guard: searchParams is only available in client
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
return params.get("id");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TradeInPage() {
|
function TradeInInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
// App Router hook: reactive to URL changes and SSR-safe (no hydration mismatch).
|
||||||
|
const urlEstimateId = useSearchParams().get("id");
|
||||||
|
|
||||||
// Result from mutation (fresh) or from URL (restored)
|
// Result from mutation (fresh) or from URL (restored)
|
||||||
const [freshResult, setFreshResult] = useState<{
|
const [freshResult, setFreshResult] = useState<{
|
||||||
|
|
@ -32,10 +25,14 @@ export default function TradeInPage() {
|
||||||
input: TradeInEstimateInput;
|
input: TradeInEstimateInput;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const urlEstimateId = useEstimateId();
|
// Drop the fresh result when the URL points at a different estimate so a
|
||||||
// Fetch from URL if no fresh result yet
|
// soft-navigation to another shared link doesn't keep showing the old one.
|
||||||
|
const [freshFor, setFreshFor] = useState<string | null>(null);
|
||||||
|
const activeFreshResult = freshFor === urlEstimateId ? freshResult : null;
|
||||||
|
|
||||||
|
// Fetch from URL if no fresh result for the current id yet
|
||||||
const restoredEstimate = useEstimate(
|
const restoredEstimate = useEstimate(
|
||||||
freshResult === null ? urlEstimateId : null,
|
activeFreshResult === null ? urlEstimateId : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mutation = useEstimateMutation();
|
const mutation = useEstimateMutation();
|
||||||
|
|
@ -44,6 +41,9 @@ export default function TradeInPage() {
|
||||||
mutation.mutate(input, {
|
mutation.mutate(input, {
|
||||||
onSuccess: (estimate) => {
|
onSuccess: (estimate) => {
|
||||||
setFreshResult({ estimate, input });
|
setFreshResult({ estimate, input });
|
||||||
|
// Bind the fresh result to its id so it's only shown while the URL
|
||||||
|
// still points at this estimate (see activeFreshResult).
|
||||||
|
setFreshFor(estimate.estimate_id);
|
||||||
// Persist estimate_id in URL for shareable link
|
// Persist estimate_id in URL for shareable link
|
||||||
router.replace(`/trade-in?id=${estimate.estimate_id}`, {
|
router.replace(`/trade-in?id=${estimate.estimate_id}`, {
|
||||||
scroll: false,
|
scroll: false,
|
||||||
|
|
@ -55,17 +55,24 @@ export default function TradeInPage() {
|
||||||
const apiError = mutation.error?.message ?? null;
|
const apiError = mutation.error?.message ?? null;
|
||||||
|
|
||||||
// Determine what to render on the right side
|
// Determine what to render on the right side
|
||||||
|
const restoreError = restoredEstimate.error?.message ?? null;
|
||||||
|
|
||||||
const resultData: {
|
const resultData: {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
input: TradeInEstimateInput;
|
input: TradeInEstimateInput;
|
||||||
} | null =
|
} | null =
|
||||||
freshResult ??
|
activeFreshResult ??
|
||||||
(restoredEstimate.data
|
(restoredEstimate.data
|
||||||
? {
|
? {
|
||||||
estimate: restoredEstimate.data,
|
estimate: restoredEstimate.data,
|
||||||
// When restoring from URL we don't have the original input — synthesise a minimal one
|
// When restoring from URL we don't have the original object parameters
|
||||||
|
// (GET /estimate/{id} returns only AggregatedEstimate, no input snapshot).
|
||||||
|
// Don't borrow the first analog's address or fabricate 0 m²/floor — those
|
||||||
|
// are someone else's listing. Pass an empty input; EstimateResult must
|
||||||
|
// hide the "Параметры объекта" block when the object params are unknown.
|
||||||
|
// TODO(TI): expose the stored input on the GET endpoint + render real params.
|
||||||
input: {
|
input: {
|
||||||
address: restoredEstimate.data.analogs[0]?.address ?? "—",
|
address: "",
|
||||||
area_m2: 0,
|
area_m2: 0,
|
||||||
rooms: 0,
|
rooms: 0,
|
||||||
floor: 0,
|
floor: 0,
|
||||||
|
|
@ -131,6 +138,8 @@ export default function TradeInPage() {
|
||||||
/>
|
/>
|
||||||
) : restoredEstimate.isLoading ? (
|
) : restoredEstimate.isLoading ? (
|
||||||
<EstimateProgress visible />
|
<EstimateProgress visible />
|
||||||
|
) : restoreError ? (
|
||||||
|
<RestoreError />
|
||||||
) : (
|
) : (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
)}
|
)}
|
||||||
|
|
@ -152,6 +161,16 @@ export default function TradeInPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useSearchParams() в Next 15 App Router throw'ит при static rendering без
|
||||||
|
// <Suspense> boundary — поэтому оборачиваем page-level.
|
||||||
|
export default function TradeInPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<TradeInInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -181,3 +200,34 @@ function EmptyState() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RestoreError() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "48px 24px",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#b91c1c",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #fca5a5",
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 40, marginBottom: 12 }}>⚠️</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#991b1b",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ссылка устарела или не найдена
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: "#6b7280" }}>
|
||||||
|
Оценка по этой ссылке недоступна — срок её хранения истёк. Введите
|
||||||
|
параметры квартиры слева, чтобы рассчитать новую.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,12 @@ export function BulkGeoPanel() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parallelismValid =
|
||||||
|
Number.isInteger(parallelism) && parallelism >= 1 && parallelism <= 10;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!parallelismValid) return;
|
||||||
bulkMutation.mutate();
|
bulkMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -168,20 +172,23 @@ export function BulkGeoPanel() {
|
||||||
<span style={labelStyle}>Parallelism (1–10)</span>
|
<span style={labelStyle}>Parallelism (1–10)</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={parallelism}
|
value={Number.isNaN(parallelism) ? "" : parallelism}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
onChange={(e) => setParallelism(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
const next = Number(e.target.value);
|
||||||
|
setParallelism(e.target.value === "" ? NaN : next);
|
||||||
|
}}
|
||||||
style={{ ...numInput }}
|
style={{ ...numInput }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={bulkMutation.isPending}
|
disabled={bulkMutation.isPending || !parallelismValid}
|
||||||
style={
|
style={
|
||||||
bulkMutation.isPending
|
bulkMutation.isPending || !parallelismValid
|
||||||
? { ...triggerBtn, opacity: 0.6 }
|
? { ...triggerBtn, opacity: 0.6, cursor: "not-allowed" }
|
||||||
: triggerBtn
|
: triggerBtn
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,12 @@ export function JobSettingsPanel() {
|
||||||
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
|
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
// Track in-flight saves per job_type — единственная updateMutation хранит
|
||||||
|
// только variables последнего mutate, поэтому при параллельных Save
|
||||||
|
// спиннер/opacity рассинхронизировались (issue #1429). Ведём множество.
|
||||||
|
const [pendingJobTypes, setPendingJobTypes] = useState<Set<string>>(
|
||||||
|
() => new Set<string>(),
|
||||||
|
);
|
||||||
const toastSeqRef = useRef(0);
|
const toastSeqRef = useRef(0);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -132,6 +138,18 @@ export function JobSettingsPanel() {
|
||||||
"error",
|
"error",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onSettled: (
|
||||||
|
_data: JobSettingRead | undefined,
|
||||||
|
_err: unknown,
|
||||||
|
variables: { job_type: string; update: JobSettingUpdate },
|
||||||
|
) => {
|
||||||
|
setPendingJobTypes((prev: Set<string>) => {
|
||||||
|
if (!prev.has(variables.job_type)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(variables.job_type);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchDraft = (job_type: string, patch: Partial<RowDraft>) => {
|
const patchDraft = (job_type: string, patch: Partial<RowDraft>) => {
|
||||||
|
|
@ -197,22 +215,27 @@ export function JobSettingsPanel() {
|
||||||
cron_schedule: draft.cron_schedule.trim() || null,
|
cron_schedule: draft.cron_schedule.trim() || null,
|
||||||
rate_ms: draft.rate_ms !== "" ? Number(draft.rate_ms) : null,
|
rate_ms: draft.rate_ms !== "" ? Number(draft.rate_ms) : null,
|
||||||
max_retries: Number(draft.max_retries),
|
max_retries: Number(draft.max_retries),
|
||||||
max_concurrency: Number(draft.max_concurrency),
|
|
||||||
};
|
};
|
||||||
|
// Очистка max_concurrency давала Number("")===0, а backend требует ge=1 →
|
||||||
|
// 422 (issue #1430). При пустом поле не включаем его в update — сервер
|
||||||
|
// оставит прежнее значение, onSuccess вернёт его в draft.
|
||||||
|
if (draft.max_concurrency !== "") {
|
||||||
|
update.max_concurrency = Number(draft.max_concurrency);
|
||||||
|
}
|
||||||
if (extra_config !== undefined) {
|
if (extra_config !== undefined) {
|
||||||
update.extra_config = extra_config;
|
update.extra_config = extra_config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPendingJobTypes((prev: Set<string>) => {
|
||||||
|
if (prev.has(job_type)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(job_type);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
updateMutation.mutate({ job_type, update });
|
updateMutation.mutate({ job_type, update });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPendingFor = (job_type: string) =>
|
const isPendingFor = (job_type: string) => pendingJobTypes.has(job_type);
|
||||||
updateMutation.isPending &&
|
|
||||||
(
|
|
||||||
updateMutation.variables as
|
|
||||||
| { job_type: string; update: JobSettingUpdate }
|
|
||||||
| undefined
|
|
||||||
)?.job_type === job_type;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const COLS: {
|
||||||
{
|
{
|
||||||
key: "sverdl_sqm_th",
|
key: "sverdl_sqm_th",
|
||||||
label: "тыс м²",
|
label: "тыс м²",
|
||||||
format: (v) => (v ? v.toFixed(0) : "—"),
|
format: (v) => (v != null ? v.toFixed(0) : "—"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "sold_pct",
|
key: "sold_pct",
|
||||||
|
|
@ -37,12 +37,12 @@ const COLS: {
|
||||||
{
|
{
|
||||||
key: "avg_area_sqm",
|
key: "avg_area_sqm",
|
||||||
label: "ср. м²",
|
label: "ср. м²",
|
||||||
format: (v) => (v ? v.toFixed(1) : "—"),
|
format: (v) => (v != null ? v.toFixed(1) : "—"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "pct_three_plus",
|
key: "pct_three_plus",
|
||||||
label: "3-к+ %",
|
label: "3-к+ %",
|
||||||
format: (v) => (v ? v.toFixed(0) : "—"),
|
format: (v) => (v != null ? v.toFixed(0) : "—"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
const [activeCat, setActiveCat] = useState<string | null>(null);
|
const [activeCat, setActiveCat] = useState<string | null>(null);
|
||||||
const [shown, setShown] = useState(PAGE_SIZE);
|
const [shown, setShown] = useState(PAGE_SIZE);
|
||||||
const { data, isLoading, isFetching } = useObjectInfrastructure(objId, {
|
const { data, isLoading, isFetching } = useObjectInfrastructure(objId, {
|
||||||
category: activeCat ?? undefined,
|
|
||||||
max_distance: maxDist,
|
max_distance: maxDist,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -44,6 +43,14 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
return Object.entries(byCat).sort((a, b) => b[1] - a[1]);
|
return Object.entries(byCat).sort((a, b) => b[1] - a[1]);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() =>
|
||||||
|
activeCat == null
|
||||||
|
? (data ?? [])
|
||||||
|
: (data ?? []).filter((p) => (p.poi_category ?? "—") === activeCat),
|
||||||
|
[data, activeCat],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -144,7 +151,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(data ?? []).slice(0, shown).map((p, i) => (
|
{filtered.slice(0, shown).map((p, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${p.poi_name}-${p.lat}-${p.lon}`}
|
key={`${p.poi_name}-${p.lat}-${p.lon}`}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -184,7 +191,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{(data?.length ?? 0) > shown ? (
|
{filtered.length > shown ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: 10,
|
padding: 10,
|
||||||
|
|
@ -204,7 +211,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Показать ещё {Math.min(PAGE_SIZE, (data?.length ?? 0) - shown)}
|
Показать ещё {Math.min(PAGE_SIZE, filtered.length - shown)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,78 @@ export function PrinzipGapBar() {
|
||||||
|
|
||||||
const option = useMemo(() => {
|
const option = useMemo(() => {
|
||||||
const rows = data?.key_gaps ?? [];
|
const rows = data?.key_gaps ?? [];
|
||||||
|
|
||||||
|
// Метраж (м²) и доли (%) несравнимы по длине бара на общей шкале, поэтому
|
||||||
|
// строки разносятся по единице измерения на отдельные value-оси/гриды —
|
||||||
|
// длины баров сопоставимы только внутри своей единицы. Сохраняем исходный
|
||||||
|
// порядок строк внутри каждой группы.
|
||||||
|
const units = Array.from(new Set(rows.map((r) => r.unit)));
|
||||||
|
const groups = units.map((unit) => ({
|
||||||
|
unit,
|
||||||
|
rows: rows.filter((r) => r.unit === unit),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const developers = [
|
||||||
|
{ key: "prinzip" as const, name: "PRINZIP", color: "#1d4ed8" },
|
||||||
|
{ key: "market" as const, name: "Рынок Свердл", color: "#94a3b8" },
|
||||||
|
{ key: "brusnika" as const, name: "Брусника", color: "#0a7a3a" },
|
||||||
|
{ key: "forum" as const, name: "Форум-групп", color: "#c2410c" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Вертикальная раскладка гридов: верхний отступ под легенду, нижний под ось,
|
||||||
|
// высота гридов пропорциональна числу строк в группе.
|
||||||
|
const TOP = 56;
|
||||||
|
const BOTTOM = 16;
|
||||||
|
const GAP = 48;
|
||||||
|
const totalRows = rows.length || 1;
|
||||||
|
const span = 100 - TOP - BOTTOM - GAP * Math.max(groups.length - 1, 0);
|
||||||
|
|
||||||
|
let cursor = TOP;
|
||||||
|
const grids: Record<string, unknown>[] = [];
|
||||||
|
const xAxes: Record<string, unknown>[] = [];
|
||||||
|
const yAxes: Record<string, unknown>[] = [];
|
||||||
|
const groupBounds = groups.map((g) => {
|
||||||
|
const height = (g.rows.length / totalRows) * span;
|
||||||
|
const top = cursor;
|
||||||
|
const bottom = 100 - (top + height);
|
||||||
|
cursor = top + height + GAP;
|
||||||
|
return { top, bottom };
|
||||||
|
});
|
||||||
|
|
||||||
|
groups.forEach((g, i) => {
|
||||||
|
const { top, bottom } = groupBounds[i];
|
||||||
|
grids.push({
|
||||||
|
left: 120,
|
||||||
|
right: 32,
|
||||||
|
top: `${top}%`,
|
||||||
|
bottom: `${bottom}%`,
|
||||||
|
containLabel: false,
|
||||||
|
});
|
||||||
|
xAxes.push({
|
||||||
|
type: "value",
|
||||||
|
gridIndex: i,
|
||||||
|
name: g.unit,
|
||||||
|
nameLocation: "end",
|
||||||
|
nameGap: 8,
|
||||||
|
});
|
||||||
|
yAxes.push({
|
||||||
|
type: "category",
|
||||||
|
gridIndex: i,
|
||||||
|
data: g.rows.map((r) => r.label),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const series = groups.flatMap((g, i) =>
|
||||||
|
developers.map((dev) => ({
|
||||||
|
name: dev.name,
|
||||||
|
type: "bar",
|
||||||
|
xAxisIndex: i,
|
||||||
|
yAxisIndex: i,
|
||||||
|
data: g.rows.map((r) => r[dev.key]),
|
||||||
|
itemStyle: { color: dev.color },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
|
|
@ -33,36 +105,11 @@ export function PrinzipGapBar() {
|
||||||
].join("<br/>");
|
].join("<br/>");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { data: ["PRINZIP", "Рынок Свердл", "Брусника", "Форум-групп"] },
|
legend: { data: developers.map((d) => d.name) },
|
||||||
grid: { left: 120, right: 32, top: 40, bottom: 28 },
|
grid: grids,
|
||||||
xAxis: { type: "value" },
|
xAxis: xAxes,
|
||||||
yAxis: { type: "category", data: rows.map((r) => r.label) },
|
yAxis: yAxes,
|
||||||
series: [
|
series,
|
||||||
{
|
|
||||||
name: "PRINZIP",
|
|
||||||
type: "bar",
|
|
||||||
data: rows.map((r) => r.prinzip),
|
|
||||||
itemStyle: { color: "#1d4ed8" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Рынок Свердл",
|
|
||||||
type: "bar",
|
|
||||||
data: rows.map((r) => r.market),
|
|
||||||
itemStyle: { color: "#94a3b8" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Брусника",
|
|
||||||
type: "bar",
|
|
||||||
data: rows.map((r) => r.brusnika),
|
|
||||||
itemStyle: { color: "#0a7a3a" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Форум-групп",
|
|
||||||
type: "bar",
|
|
||||||
data: rows.map((r) => r.forum),
|
|
||||||
itemStyle: { color: "#c2410c" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,12 @@ export function QuartirographyChart() {
|
||||||
"3-к 60-80",
|
"3-к 60-80",
|
||||||
"80+ м²",
|
"80+ м²",
|
||||||
];
|
];
|
||||||
const portfolioMap: Record<string, number> = {
|
// DOM.РФ portfolio source (domrf_region_aggregates) has no studio segment
|
||||||
|
// (room_count_type only ONE/TWO/THREE/FOUR), so "Студии 15-30" has no
|
||||||
|
// counterpart here. Map it to null → ECharts draws no bar (N/A) instead of
|
||||||
|
// a misleading 0% grey bar.
|
||||||
|
const portfolioMap: Record<string, number | null> = {
|
||||||
|
"Студии 15-30": null,
|
||||||
"1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0,
|
"1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0,
|
||||||
"2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0,
|
"2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0,
|
||||||
"3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0,
|
"3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0,
|
||||||
|
|
@ -32,13 +37,13 @@ export function QuartirographyChart() {
|
||||||
const dealsPercents = buckets.map(
|
const dealsPercents = buckets.map(
|
||||||
(b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0,
|
(b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0,
|
||||||
);
|
);
|
||||||
const portfolioPercents = buckets.map((b) => portfolioMap[b] ?? 0);
|
const portfolioPercents = buckets.map((b) => portfolioMap[b] ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
axisPointer: { type: "shadow" },
|
axisPointer: { type: "shadow" },
|
||||||
valueFormatter: (v: number) => `${v}%`,
|
valueFormatter: (v: number | null) => (v == null ? "нет данных" : `${v}%`),
|
||||||
},
|
},
|
||||||
legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] },
|
legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] },
|
||||||
grid: { left: 110, right: 32, top: 40, bottom: 28 },
|
grid: { left: 110, right: 32, top: 40, bottom: 28 },
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function RecommendRevenueChart({ bucketLabels, revenuesRub }: Props) {
|
||||||
itemStyle: { color: COLORS[i % COLORS.length] },
|
itemStyle: { color: COLORS[i % COLORS.length] },
|
||||||
emphasis: { focus: "series" },
|
emphasis: { focus: "series" },
|
||||||
label: {
|
label: {
|
||||||
show: (valuesMln[i] ?? 0) >= total * 0.05,
|
show: total > 0 && (valuesMln[i] ?? 0) >= total * 0.05,
|
||||||
formatter: (p: { value: number; seriesName: string }) =>
|
formatter: (p: { value: number; seriesName: string }) =>
|
||||||
`${p.seriesName}\n${p.value.toFixed(1)} млн`,
|
`${p.seriesName}\n${p.value.toFixed(1)} млн`,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,13 @@ export function RecommendVelocityPanel({
|
||||||
const bucketPfPow = priceFactor > 0 ? priceFactor ** be : 1;
|
const bucketPfPow = priceFactor > 0 ? priceFactor ** be : 1;
|
||||||
const adjustedV = v * bucketPfPow;
|
const adjustedV = v * bucketPfPow;
|
||||||
adjustedVelocity += adjustedV * shareRatio;
|
adjustedVelocity += adjustedV * shareRatio;
|
||||||
if (u > 0 && adjustedV > 0) {
|
// Liquidity-24 KPI должен совпадать с точкой кривой
|
||||||
const months = u / adjustedV;
|
// RecommendLiquidityChart на 24 мес (подпись секции обещает равенство).
|
||||||
|
// График считает sellout по ЕДИНОЙ scope-эластичности (v × pfPow),
|
||||||
|
// поэтому здесь тоже используем pfPow, а не per-bucket bucketPfPow.
|
||||||
|
const liquidityV = v * pfPow;
|
||||||
|
if (u > 0 && liquidityV > 0) {
|
||||||
|
const months = u / liquidityV;
|
||||||
const fracIn24 = Math.min(1, 24 / months);
|
const fracIn24 = Math.min(1, 24 / months);
|
||||||
weightedSold24 += fracIn24 * u;
|
weightedSold24 += fracIn24 * u;
|
||||||
}
|
}
|
||||||
|
|
@ -492,8 +497,11 @@ export function RecommendVelocityPanel({
|
||||||
? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)`
|
? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)`
|
||||||
: ""}{" "}
|
: ""}{" "}
|
||||||
· <strong>POI ×{scope.poi_factor.toFixed(2)}</strong> (на цену) ). При
|
· <strong>POI ×{scope.poi_factor.toFixed(2)}</strong> (на цену) ). При
|
||||||
price ×{priceFactor.toFixed(2)} темп = базовый ×{" "}
|
price ×{priceFactor.toFixed(2)} ценовой множитель темпа по общей
|
||||||
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
эластичности = {priceFactor.toFixed(2)}^{elasticity} = ×
|
||||||
|
{pfPow.toFixed(3)}. Показанный «Темп продаж» суммирует бакеты с их
|
||||||
|
собственной эластичностью (Tier 3) и текущим распределением долей,
|
||||||
|
поэтому может отличаться от базовый ×{pfPow.toFixed(3)}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,17 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
// #799: pilot-only юзер (единственная доступная зона — trade-in) на
|
// #799: pilot-only юзер (единственная доступная зона — trade-in) на
|
||||||
// запрещённом пути главного фронта (корень `/` и пр.) → авто-redirect в
|
// запрещённом пути главного фронта (корень `/` и пр.) → авто-redirect в
|
||||||
// /trade-in/ вместо дед-энда «Доступа нет». Условия:
|
// /trade-in/ вместо дед-энда «Доступа нет». Условия:
|
||||||
|
// - роль pilot (trade-in — её ЕДИНСТВЕННАЯ рабочая зона; paths scoped
|
||||||
|
// только на /trade-in/**). #1439: без этой проверки multi-zone роли
|
||||||
|
// (напр. analyst с paths="/**") на запрещённом /admin/* молча
|
||||||
|
// редиректились в /trade-in/ вместо честного NoAccessScreen;
|
||||||
// - текущий путь НЕ под /trade-in (иначе цикл; /trade-in — отдельный
|
// - текущий путь НЕ под /trade-in (иначе цикл; /trade-in — отдельный
|
||||||
// bundle за Caddy, этот гард его не обслуживает, но guard на всякий);
|
// bundle за Caddy, этот гард его не обслуживает, но guard на всякий);
|
||||||
// - у роли есть доступ к /trade-in/ (т.е. trade-in — её рабочая зона).
|
// - у роли есть доступ к /trade-in/ (т.е. trade-in — её рабочая зона).
|
||||||
// admin / multi-scope с разрешённым `/` сюда не попадают (isPathAllowed=true).
|
// admin / multi-scope с разрешённым `/` сюда не попадают (isPathAllowed=true).
|
||||||
if (
|
if (
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
|
data.role === "pilot" &&
|
||||||
!pathname.startsWith("/trade-in") &&
|
!pathname.startsWith("/trade-in") &&
|
||||||
isPathAllowed(data.allowed_paths, data.deny_paths, "/trade-in/")
|
isPathAllowed(data.allowed_paths, data.deny_paths, "/trade-in/")
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,11 @@ export function UserMenu() {
|
||||||
if (isLoading || error || !data) return null;
|
if (isLoading || error || !data) return null;
|
||||||
|
|
||||||
const initial = data.username[0]?.toUpperCase() ?? "?";
|
const initial = data.username[0]?.toUpperCase() ?? "?";
|
||||||
const roleLabel = ROLE_LABELS[data.role];
|
// Runtime-fallback: TS гарантирует синхрон ROLE_LABELS только compile-time.
|
||||||
|
// Если backend добавит роль в roles.yaml и /me вернёт её до обновления фронта,
|
||||||
|
// ROLE_LABELS[data.role] === undefined → badge отрендерится пустым. Падаем
|
||||||
|
// на сырое значение роли, чтобы badge всегда был подписан.
|
||||||
|
const roleLabel = ROLE_LABELS[data.role] ?? data.role;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ position: "relative" }}>
|
<div ref={containerRef} style={{ position: "relative" }}>
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,9 @@ function DrawLayer({
|
||||||
|
|
||||||
const onCreated = (e: L.LeafletEvent) => {
|
const onCreated = (e: L.LeafletEvent) => {
|
||||||
const { layer } = e as L.DrawEvents.Created;
|
const { layer } = e as L.DrawEvents.Created;
|
||||||
group.clearLayers();
|
// Don't add the layer to the FeatureGroup: the polygon is controlled by
|
||||||
group.addLayer(layer);
|
// the parent via the `polygon` prop, which renders it as a <GeoJSON>.
|
||||||
|
// Adding it here would draw a second overlapping copy of the same ring.
|
||||||
const gj = (layer as L.Polygon).toGeoJSON();
|
const gj = (layer as L.Polygon).toGeoJSON();
|
||||||
if (gj.type === "Feature" && gj.geometry.type === "Polygon") {
|
if (gj.type === "Feature" && gj.geometry.type === "Polygon") {
|
||||||
onPolygon(gj.geometry);
|
onPolygon(gj.geometry);
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,17 @@ export function ConceptExportButtons({ variant }: Props) {
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The parent renders one reused instance per tab (no `key`), so switching
|
||||||
|
// variants only changes this prop without remounting. Reset stale export
|
||||||
|
// state during render when the variant changes, otherwise an error message
|
||||||
|
// or in-flight disabled state would leak onto another variant's controls.
|
||||||
|
const [prevStrategy, setPrevStrategy] = useState(variant.strategy);
|
||||||
|
if (prevStrategy !== variant.strategy) {
|
||||||
|
setPrevStrategy(variant.strategy);
|
||||||
|
setServerLoading(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
const fileStem = `gendesign_concept_${variant.strategy}_${today()}`;
|
const fileStem = `gendesign_concept_${variant.strategy}_${today()}`;
|
||||||
|
|
||||||
function handleGeojson() {
|
function handleGeojson() {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* Lazy-mounted (ssr:false) by the parent — Leaflet touches `window`.
|
* Lazy-mounted (ssr:false) by the parent — Leaflet touches `window`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
|
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
|
||||||
import type { FeatureCollection, Polygon, Position } from "geojson";
|
import type { FeatureCollection, Polygon, Position } from "geojson";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
|
|
@ -67,7 +67,13 @@ export function ConceptResultMap({
|
||||||
variantKey,
|
variantKey,
|
||||||
height = 360,
|
height = 360,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const bounds = ringBounds(parcel.coordinates as Position[][]);
|
// Memoize so FitBounds only re-fits when the parcel geometry actually
|
||||||
|
// changes — recomputing inline returns a new array each render, which would
|
||||||
|
// make the fitBounds effect fire on every re-render and discard manual zoom/pan.
|
||||||
|
const bounds = useMemo(
|
||||||
|
() => ringBounds(parcel.coordinates as Position[][]),
|
||||||
|
[parcel.coordinates],
|
||||||
|
);
|
||||||
const hasBuildings = buildings.features.length > 0;
|
const hasBuildings = buildings.features.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,13 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
color: "var(--fg-on-dark-muted)",
|
color: "var(--fg-on-dark-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} ·
|
{STRATEGY_HINTS[variant.strategy]} IRR-proxy{" "}
|
||||||
плотность {teap.density.toLocaleString("ru-RU")} м²/га.
|
{formatPct(financial.irr)} · плотность (FAR){" "}
|
||||||
|
{teap.density.toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -158,9 +163,11 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
unit="шт"
|
unit="шт"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Плотность"
|
label="Плотность (FAR)"
|
||||||
value={teap.density.toLocaleString("ru-RU")}
|
value={teap.density.toLocaleString("ru-RU", {
|
||||||
unit="м²/га"
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Машино-мест"
|
label="Машино-мест"
|
||||||
|
|
@ -196,16 +203,21 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
value={formatMoneyCompact(financial.gross_margin_rub)}
|
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||||||
delta={{
|
delta={{
|
||||||
value:
|
value:
|
||||||
marginPositive === false
|
marginPositive === true
|
||||||
? "Отрицательная маржа"
|
? "Положительная маржа"
|
||||||
: "Положительная маржа",
|
: marginPositive === false
|
||||||
|
? "Отрицательная маржа"
|
||||||
|
: "Нулевая маржа",
|
||||||
positive: marginPositive,
|
positive: marginPositive,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="IRR"
|
label="IRR-proxy"
|
||||||
value={formatPct(financial.irr)}
|
value={formatPct(financial.irr)}
|
||||||
delta={{ value: "Внутренняя норма доходности", positive: null }}
|
delta={{
|
||||||
|
value: "Упрощённая оценка: маржа на затраты, без дисконтирования",
|
||||||
|
positive: null,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,17 @@ export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Derives a human-readable tracking ID from the UUID string returned by backend.
|
/** Derives a human-readable tracking ID from the UUID string returned by backend.
|
||||||
* Example: "7f3a8c2e-..." → "GD-7F3A8C2E"
|
*
|
||||||
|
* Важно: показанный лиду номер ДОЛЖЕН находиться поддержкой в БД. Бэкенд
|
||||||
|
* (`backend/app/api/v1/pilot.py`) хранит и логирует полный uuid `id`, поэтому
|
||||||
|
* мы берём его реальный 8-символьный префикс БЕЗ изменения регистра — он остаётся
|
||||||
|
* буквальным префиксом `pilot_requests.id`. Поддержка находит заявку прямым
|
||||||
|
* запросом: `SELECT * FROM pilot_requests WHERE id::text LIKE 'XXXXXXXX%'`.
|
||||||
|
* Префикс "GD-" — только визуальный, в запрос НЕ входит.
|
||||||
|
* Example: "7f3a8c2e-..." → "GD-7f3a8c2e"
|
||||||
*/
|
*/
|
||||||
function deriveTrackingId(id: string): string {
|
function deriveTrackingId(id: string): string {
|
||||||
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
|
return "GD-" + id.replace(/-/g, "").slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
|
||||||
last_year: "Последний год",
|
last_year: "Последний год",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Период «распроданности» для провенанса — соответствует выбранному time_window
|
||||||
|
// (backend считает sold_pct_of_supply по сделкам этого окна).
|
||||||
|
const TIME_WINDOW_OVERSOLD_PERIOD: Record<TimeWindow, string> = {
|
||||||
|
last_month: "за последний месяц",
|
||||||
|
last_quarter: "за последний квартал",
|
||||||
|
last_year: "за последний год",
|
||||||
|
};
|
||||||
|
|
||||||
const ROOM_BUCKET_LABELS: Record<string, string> = {
|
const ROOM_BUCKET_LABELS: Record<string, string> = {
|
||||||
studio: "Студия",
|
studio: "Студия",
|
||||||
"1": "1-комн.",
|
"1": "1-комн.",
|
||||||
|
|
@ -138,7 +146,13 @@ function CompetitorDrillIn({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
|
function TopLayoutsTable({
|
||||||
|
rows,
|
||||||
|
oversoldPeriodLabel,
|
||||||
|
}: {
|
||||||
|
rows: TopLayoutRow[];
|
||||||
|
oversoldPeriodLabel: string;
|
||||||
|
}) {
|
||||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|
@ -333,7 +347,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%"
|
title={`Сделки ${oversoldPeriodLabel} превышают текущий snapshot supply — значение обрезано до 100%`}
|
||||||
>
|
>
|
||||||
>100%
|
>100%
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -655,6 +669,11 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
|
||||||
const [minVelocity, setMinVelocity] = useState(0.5);
|
const [minVelocity, setMinVelocity] = useState(0.5);
|
||||||
const [isPdfLoading, setIsPdfLoading] = useState(false);
|
const [isPdfLoading, setIsPdfLoading] = useState(false);
|
||||||
const [pdfError, setPdfError] = useState<string | null>(null);
|
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||||
|
// Request that produced the currently shown `data` — PDF must use this,
|
||||||
|
// not live control state (controls may have changed without re-running «Рассчитать»).
|
||||||
|
const [lastRequest, setLastRequest] = useState<BestLayoutsRequest | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const { mutate, data, isPending, error } = useBestLayouts(cadNum);
|
const { mutate, data, isPending, error } = useBestLayouts(cadNum);
|
||||||
|
|
||||||
|
|
@ -676,13 +695,17 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCalculate() {
|
function handleCalculate() {
|
||||||
mutate(buildRequest());
|
const req = buildRequest();
|
||||||
|
setLastRequest(req);
|
||||||
|
mutate(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownloadPdf() {
|
async function handleDownloadPdf() {
|
||||||
|
// Use the request that produced the shown `data`, not live control state.
|
||||||
|
if (!lastRequest) return;
|
||||||
setIsPdfLoading(true);
|
setIsPdfLoading(true);
|
||||||
try {
|
try {
|
||||||
const req = buildRequest();
|
const req = lastRequest;
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`,
|
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`,
|
||||||
{
|
{
|
||||||
|
|
@ -994,7 +1017,14 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
|
||||||
{data && !isPending && (
|
{data && !isPending && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<DataQualityCard dq={data.data_quality} />
|
<DataQualityCard dq={data.data_quality} />
|
||||||
<TopLayoutsTable rows={data.top_layouts} />
|
<TopLayoutsTable
|
||||||
|
rows={data.top_layouts}
|
||||||
|
oversoldPeriodLabel={
|
||||||
|
TIME_WINDOW_OVERSOLD_PERIOD[
|
||||||
|
(lastRequest ?? buildRequest()).time_window
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
<RecommendationCard rec={data.recommendation_for_tz} />
|
<RecommendationCard rec={data.recommendation_for_tz} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,15 @@ export function CpLayerControlPanel({
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const totalCount = data?.engineering_structures.length ?? 0;
|
const totalCount = data?.engineering_structures.length ?? 0;
|
||||||
const allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length;
|
// #1459 — чекбоксы рендерятся только для непустых категорий, поэтому
|
||||||
|
// «Показать все» должен отражать состояние именно доступных пользователю
|
||||||
|
// (непустых) категорий, а не всех CP_ALL_CATEGORIES (включая пустые).
|
||||||
|
const togglableCategories = CP_ALL_CATEGORIES.filter(
|
||||||
|
(cat) => (grouped.get(cat)?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const allVisible =
|
||||||
|
togglableCategories.length > 0 &&
|
||||||
|
togglableCategories.every((cat) => visibleCategories.has(cat));
|
||||||
const hasMarketLayers = (marketLayers?.length ?? 0) > 0;
|
const hasMarketLayers = (marketLayers?.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ function WeatherBlock({
|
||||||
}}
|
}}
|
||||||
title={weather.note}
|
title={weather.note}
|
||||||
>
|
>
|
||||||
{weather.source} · 7 дн.
|
{weather.source} · {weather.forecast_days} дн.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export function MarketTab({ data }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
{activeCount} строящихся в {radiusKm}км
|
{activeCount} активных в {radiusKm}км
|
||||||
</strong>
|
</strong>
|
||||||
{" · велосити "}
|
{" · велосити "}
|
||||||
<strong>
|
<strong>
|
||||||
|
|
@ -147,7 +147,7 @@ export function MarketTab({ data }: Props) {
|
||||||
{activeCount}
|
{activeCount}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
|
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
|
||||||
строящихся в радиусе {radiusKm}км
|
строящихся и недавно сданных в радиусе {radiusKm}км
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,10 @@ export function MarketTrendBlock({ trend }: Props) {
|
||||||
|
|
||||||
const arrow = trendArrow(trend.delta_6m_pct);
|
const arrow = trendArrow(trend.delta_6m_pct);
|
||||||
const arrowColor = deltaColor(trend.delta_6m_pct);
|
const arrowColor = deltaColor(trend.delta_6m_pct);
|
||||||
const labelStyle = LABEL_COLORS[trend.label] ?? {
|
// Backend кладёт длинный label ("Сильный рост — рынок растёт быстрее
|
||||||
|
// инфляции"); ключи LABEL_COLORS — короткие, поэтому нормализуем по префиксу.
|
||||||
|
const labelKey = trend.label.split(" — ")[0];
|
||||||
|
const labelStyle = LABEL_COLORS[labelKey] ?? {
|
||||||
bg: "#f3f4f6",
|
bg: "#f3f4f6",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ interface MergedRow {
|
||||||
type: string | null;
|
type: string | null;
|
||||||
distanceM: number;
|
distanceM: number;
|
||||||
source: "analyze" | "connection-points";
|
source: "analyze" | "connection-points";
|
||||||
|
// Stable structure identifier (НСПД cad_num) when available — используется для
|
||||||
|
// дедупа между источниками, т.к. distanceM меряется до разных опорных точек
|
||||||
|
// (analyze — до центроида, connection-points — до границы участка).
|
||||||
|
cadNum: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
|
|
@ -28,6 +32,7 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
type: item.type,
|
type: item.type,
|
||||||
distanceM: item.distance_m,
|
distanceM: item.distance_m,
|
||||||
source: "analyze",
|
source: "analyze",
|
||||||
|
cadNum: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -39,14 +44,21 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
type: s.type,
|
type: s.type,
|
||||||
distanceM: s.distance_to_boundary_m,
|
distanceM: s.distance_to_boundary_m,
|
||||||
source: "connection-points",
|
source: "connection-points",
|
||||||
|
cadNum: s.cad_num,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedup by label+distance (simple check)
|
// Dedup. distanceM меряется до разных опорных точек (analyze — до центроида,
|
||||||
|
// connection-points — до границы), поэтому label+round(distance) пропускает
|
||||||
|
// дубли одного физического сооружения. Дедупим по стабильному cad_num, когда он
|
||||||
|
// есть; иначе (analyze без cad_num) — по label + округлённому расстоянию.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped = rows.filter((r) => {
|
const deduped = rows.filter((r) => {
|
||||||
const key = `${r.label}|${Math.round(r.distanceM)}`;
|
const key =
|
||||||
|
r.cadNum !== null
|
||||||
|
? `cad:${r.cadNum}`
|
||||||
|
: `lbl:${r.label}|${Math.round(r.distanceM)}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -57,7 +69,7 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||||
if (deduped.length === 0 && !cpLoading) {
|
if (deduped.length === 0 && !cpLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ fontSize: 13, color: "#6b7280", fontStyle: "italic" }}>
|
<div style={{ fontSize: 13, color: "#6b7280", fontStyle: "italic" }}>
|
||||||
Инженерных объектов в 200 м не найдено
|
Инженерных объектов поблизости не найдено
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ const CLASS_LABEL: Record<string, string> = {
|
||||||
|
|
||||||
function fmtMonth(iso: string | null): string {
|
function fmtMonth(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
// Прочнее чем substring(0,7) — игнорирует timezone и datetime suffix
|
// Regex-парс год/месяц напрямую — без таймзонных сюрпризов от Date()
|
||||||
|
// (new Date('2026-07-01') = UTC-полночь → в UTC-локали уезжает на 06.2026).
|
||||||
|
// Тот же приём, что в MarketLayers.formatReadyDt.
|
||||||
|
const m = /^(\d{4})-(\d{2})/.exec(iso);
|
||||||
|
if (m) return `${m[2]}.${m[1]}`;
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return iso.substring(0, 7);
|
if (Number.isNaN(d.getTime())) return iso.substring(0, 7);
|
||||||
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit" });
|
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit" });
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,9 @@ export function ScoreBreakdownStackedBar({
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{aggregated.map((agg) => {
|
{aggregated
|
||||||
|
.filter((a) => a.contribution !== 0)
|
||||||
|
.map((agg) => {
|
||||||
const color = agg.isCustom
|
const color = agg.isCustom
|
||||||
? CATEGORY_COLORS.custom
|
? CATEGORY_COLORS.custom
|
||||||
: categoryColor(agg.category);
|
: categoryColor(agg.category);
|
||||||
|
|
@ -373,11 +375,11 @@ export function ScoreBreakdownStackedBar({
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
{sourceAgg.map(({ factor, contribution }) => {
|
{sourceAgg.map(({ factor, contribution }) => {
|
||||||
const isCustom = customSet.has(factor);
|
const isCustom = customSet.has(factor);
|
||||||
|
const maxAbs =
|
||||||
|
Math.max(...sourceAgg.map((s) => Math.abs(s.contribution))) || 1;
|
||||||
const barWidth = Math.min(
|
const barWidth = Math.min(
|
||||||
100,
|
100,
|
||||||
(Math.abs(contribution) /
|
(Math.abs(contribution) / maxAbs) * 100,
|
||||||
(Math.abs(sourceAgg[0]?.contribution) || 1)) *
|
|
||||||
100,
|
|
||||||
);
|
);
|
||||||
const color =
|
const color =
|
||||||
contribution >= 0
|
contribution >= 0
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ function SeasonCard({
|
||||||
{stats.avg_t_min_c}…{stats.avg_t_max_c}°C
|
{stats.avg_t_min_c}…{stats.avg_t_max_c}°C
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||||
(макс ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
|
(экстремумы ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "#374151" }}>
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
{stats.total_precip_mm} мм осадков
|
{stats.total_precip_mm} мм осадков
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Velocity } from "@/types/site-finder";
|
||||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
|
||||||
const BUCKET_ORDER = ["студия", "1", "2", "3", "4+"] as const;
|
const BUCKET_ORDER = ["студия", "1", "2", "3", "4", "5+"] as const;
|
||||||
type KnownBucket = (typeof BUCKET_ORDER)[number];
|
type KnownBucket = (typeof BUCKET_ORDER)[number];
|
||||||
|
|
||||||
const BUCKET_LABEL: Record<KnownBucket, string> = {
|
const BUCKET_LABEL: Record<KnownBucket, string> = {
|
||||||
|
|
@ -11,7 +11,8 @@ const BUCKET_LABEL: Record<KnownBucket, string> = {
|
||||||
"1": "1-к",
|
"1": "1-к",
|
||||||
"2": "2-к",
|
"2": "2-к",
|
||||||
"3": "3-к",
|
"3": "3-к",
|
||||||
"4+": "4+",
|
"4": "4-к",
|
||||||
|
"5+": "5+",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VelocityBlockProps {
|
interface VelocityBlockProps {
|
||||||
|
|
@ -253,7 +254,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top competitors */}
|
{/* Top competitors */}
|
||||||
{velocity.sample_competitors.length > 0 && (
|
{dataAvailable && velocity.sample_competitors.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel>
|
<SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel>
|
||||||
<table
|
<table
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||||
import {
|
import {
|
||||||
|
|
@ -89,9 +89,6 @@ export function WeightProfilePanel({
|
||||||
const [saveName, setSaveName] = useState("");
|
const [saveName, setSaveName] = useState("");
|
||||||
const [saveDefault, setSaveDefault] = useState(false);
|
const [saveDefault, setSaveDefault] = useState(false);
|
||||||
|
|
||||||
// Debounce timer ref
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Profiles query (only when userId + adminToken provided)
|
// Profiles query (only when userId + adminToken provided)
|
||||||
const canUseCrud = !!userId && !!adminToken;
|
const canUseCrud = !!userId && !!adminToken;
|
||||||
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
|
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
|
||||||
|
|
@ -102,11 +99,6 @@ export function WeightProfilePanel({
|
||||||
|
|
||||||
function handleSliderChange(cat: PoiCategoryKey, raw: string) {
|
function handleSliderChange(cat: PoiCategoryKey, raw: string) {
|
||||||
const value = clamp(parseFloat(raw));
|
const value = clamp(parseFloat(raw));
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
setDraft((prev) => ({ ...prev, [cat]: value }));
|
|
||||||
}, 300);
|
|
||||||
// Optimistic visual update (no debounce for the display value input itself)
|
|
||||||
setDraft((prev) => ({ ...prev, [cat]: value }));
|
setDraft((prev) => ({ ...prev, [cat]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,11 +205,14 @@ export function ZouitLayer({ grouped, visibleSeverities }: LayerProps) {
|
||||||
return (
|
return (
|
||||||
<GeoJSON
|
<GeoJSON
|
||||||
// react-leaflet GeoJSON кэширует слой и не реагирует на смену
|
// react-leaflet GeoJSON кэширует слой и не реагирует на смену
|
||||||
// data prop — хеш в key форсирует remount при смене участка
|
// data prop — геометрия в key форсирует remount при смене
|
||||||
// (тот же приём, что у изохрон/риск-зон в SiteMap/MarketLayers).
|
// участка (тот же приём, что у изохрон/риск-зон в
|
||||||
key={`zouit-${sev}-${idx}-${
|
// SiteMap/MarketLayers). slice(0,24) GeoJSON-строки тут НЕ
|
||||||
overlap.geom_geojson?.slice(0, 24) ?? ""
|
// годится: префикс `{"type":"Polygon","coord` константен и не
|
||||||
}`}
|
// зависит от координат, поэтому при A→B с тем же числом
|
||||||
|
// полигонов в том же порядке ключи совпадали и старая
|
||||||
|
// геометрия оставалась на карте. Берём строку целиком.
|
||||||
|
key={`zouit-${sev}-${idx}-${overlap.geom_geojson ?? ""}`}
|
||||||
data={feature}
|
data={feature}
|
||||||
style={{
|
style={{
|
||||||
color: style.color,
|
color: style.color,
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,20 @@ const NAV_SECTIONS: NavSection[] = [
|
||||||
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: "section-4", label: "4. Инфраструктура" },
|
{ id: "section-4", label: "4. Оценка" },
|
||||||
{ id: "section-5", label: "5. Свежесть" },
|
{ id: "section-5", label: "5. Атмосфера" },
|
||||||
|
{
|
||||||
|
id: "section-6",
|
||||||
|
label: "6. Прогноз",
|
||||||
|
sub: [
|
||||||
|
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
||||||
|
{ id: "section-6-2", label: "6.2 Сценарии" },
|
||||||
|
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||||
|
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
||||||
|
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
|
||||||
|
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// All section IDs in scroll order (for IntersectionObserver)
|
// All section IDs in scroll order (for IntersectionObserver)
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,9 @@ interface Row {
|
||||||
|
|
||||||
function formatDate(raw: string | null): string {
|
function formatDate(raw: string | null): string {
|
||||||
if (!raw) return "—";
|
if (!raw) return "—";
|
||||||
try {
|
const d = new Date(raw);
|
||||||
return new Date(raw).toLocaleDateString("ru-RU");
|
if (Number.isNaN(d.getTime())) return raw;
|
||||||
} catch {
|
return d.toLocaleDateString("ru-RU");
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRows(data: ParcelEgrn): Row[] {
|
function buildRows(data: ParcelEgrn): Row[] {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ function factorLabel(key: string): string {
|
||||||
function formatFactorValue(value: number | null): string | null {
|
function formatFactorValue(value: number | null): string | null {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
// Fractions in 0..1 read as percent (e.g. coverage); integers/large stay raw.
|
// Fractions in 0..1 read as percent (e.g. coverage); integers/large stay raw.
|
||||||
if (value > 0 && value < 1) return `${Math.round(value * 100)}%`;
|
// Upper bound is inclusive so full coverage (1.0) renders as «100%», not «1».
|
||||||
|
if (value > 0 && value <= 1) return `${Math.round(value * 100)}%`;
|
||||||
return Math.round(value).toLocaleString("ru");
|
return Math.round(value).toLocaleString("ru");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,12 @@ interface Props {
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function PoiList2Gis({ items, totalScore }: Props) {
|
export function PoiList2Gis({ items, totalScore }: Props) {
|
||||||
|
// POI-weighted score is presented as «X / 100», so clamp to 0..100 defensively:
|
||||||
|
// the adapter sums round(weight*100) per POI без нормировки и при достаточном
|
||||||
|
// числе POI может выдать >100 → бессмысленное «137 / 100» (#1470). Корневую
|
||||||
|
// нормировку нужно чинить в site-finder-api.ts (useParcelPoiScoreQuery).
|
||||||
|
const displayScore = Math.min(100, Math.max(0, totalScore));
|
||||||
|
|
||||||
// Top-7, sorted by score_contribution desc
|
// Top-7, sorted by score_contribution desc
|
||||||
const top7 = [...items]
|
const top7 = [...items]
|
||||||
.sort((a, b) => b.score_contribution - a.score_contribution)
|
.sort((a, b) => b.score_contribution - a.score_contribution)
|
||||||
|
|
@ -130,7 +136,7 @@ export function PoiList2Gis({ items, totalScore }: Props) {
|
||||||
fontVariantNumeric: "tabular-nums",
|
fontVariantNumeric: "tabular-nums",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{totalScore.toFixed(0)} / 100
|
{displayScore.toFixed(0)} / 100
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,17 +55,25 @@ function pickHorizonForecast(
|
||||||
return [...forecasts].sort((a, b) => b.horizon_months - a.horizon_months)[0];
|
return [...forecasts].sort((a, b) => b.horizon_months - a.horizon_months)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScenarioDeficit {
|
||||||
|
value: number | null;
|
||||||
|
/** Horizon (months) the value actually corresponds to, or null when unknown. */
|
||||||
|
horizon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function deficitForScenario(
|
function deficitForScenario(
|
||||||
key: ScenarioKey,
|
key: ScenarioKey,
|
||||||
scenarios: ReportScenarios,
|
scenarios: ReportScenarios,
|
||||||
scenariosSummary: ScenariosSummary | null,
|
scenariosSummary: ScenariosSummary | null,
|
||||||
targetHorizon: number,
|
targetHorizon: number,
|
||||||
): number | null {
|
): ScenarioDeficit {
|
||||||
const sc = scenarios.by_scenario[key];
|
const sc = scenarios.by_scenario[key];
|
||||||
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
|
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
|
||||||
if (fc) return fc.deficit_index;
|
if (fc) return { value: fc.deficit_index, horizon: fc.horizon_months };
|
||||||
if (scenariosSummary) return scenariosSummary[key];
|
// scenarios_summary is, by contract, the deficit_index at the target horizon.
|
||||||
return null;
|
if (scenariosSummary)
|
||||||
|
return { value: scenariosSummary[key], horizon: targetHorizon };
|
||||||
|
return { value: null, horizon: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScenariosBlock({
|
export function ScenariosBlock({
|
||||||
|
|
@ -107,7 +115,8 @@ export function ScenariosBlock({
|
||||||
key={key}
|
key={key}
|
||||||
name={SCENARIO_RU[key]}
|
name={SCENARIO_RU[key]}
|
||||||
hint={SCENARIO_HINT[key]}
|
hint={SCENARIO_HINT[key]}
|
||||||
deficitIndex={di}
|
deficitIndex={di.value}
|
||||||
|
deficitHorizon={di.horizon}
|
||||||
ratePath={sc?.rate_path}
|
ratePath={sc?.rate_path}
|
||||||
targetHorizon={targetHorizon}
|
targetHorizon={targetHorizon}
|
||||||
/>
|
/>
|
||||||
|
|
@ -115,8 +124,10 @@ export function ScenariosBlock({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: "var(--fg-tertiary)", margin: 0 }}>
|
<p style={{ fontSize: 12, color: "var(--fg-tertiary)", margin: 0 }}>
|
||||||
Индекс дефицита приведён на целевом горизонте {targetHorizon} мес.
|
Индекс дефицита приведён на целевом горизонте {targetHorizon} мес.; если
|
||||||
Конверт ставки — путь ключевой ставки (% годовых) по горизонтам.
|
ряд на этом горизонте отсутствует, используется самый длинный доступный
|
||||||
|
— его горизонт указан в подписи карточки. Конверт ставки — путь ключевой
|
||||||
|
ставки (% годовых) по горизонтам.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -126,15 +137,20 @@ function ScenarioCard({
|
||||||
name,
|
name,
|
||||||
hint,
|
hint,
|
||||||
deficitIndex,
|
deficitIndex,
|
||||||
|
deficitHorizon,
|
||||||
ratePath,
|
ratePath,
|
||||||
targetHorizon,
|
targetHorizon,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
deficitIndex: number | null;
|
deficitIndex: number | null;
|
||||||
|
/** Horizon (months) the deficit value corresponds to; falls back to target. */
|
||||||
|
deficitHorizon: number | null;
|
||||||
ratePath: RatePath | undefined;
|
ratePath: RatePath | undefined;
|
||||||
targetHorizon: number;
|
targetHorizon: number;
|
||||||
}) {
|
}) {
|
||||||
|
// Label the number with the horizon it actually represents, not the target.
|
||||||
|
const labelHorizon = deficitHorizon ?? targetHorizon;
|
||||||
const horizons = ratePath
|
const horizons = ratePath
|
||||||
? Object.keys(ratePath)
|
? Object.keys(ratePath)
|
||||||
.map((h) => Number(h))
|
.map((h) => Number(h))
|
||||||
|
|
@ -163,7 +179,9 @@ function ScenarioCard({
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "var(--fg-tertiary)", marginTop: 2 }}>
|
<div
|
||||||
|
style={{ fontSize: 12, color: "var(--fg-tertiary)", marginTop: 2 }}
|
||||||
|
>
|
||||||
{hint}
|
{hint}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,7 +197,7 @@ function ScenarioCard({
|
||||||
color: "var(--fg-secondary)",
|
color: "var(--fg-secondary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Индекс дефицита · {targetHorizon} мес.
|
Индекс дефицита · {labelHorizon} мес.
|
||||||
</span>
|
</span>
|
||||||
{deficitIndex != null ? (
|
{deficitIndex != null ? (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -214,9 +214,12 @@ export function Section1ParcelInfo({ cad }: Props) {
|
||||||
|
|
||||||
// HeadlineBar texts
|
// HeadlineBar texts
|
||||||
const headlineTitle = scoreVerdict(data.score, data.score_label);
|
const headlineTitle = scoreVerdict(data.score, data.score_label);
|
||||||
|
// #1467: при отсутствии score_explanation (старый деплой / частичный мок /
|
||||||
|
// backend без поля) НЕ выдумываем дельту к району — формируем подзаголовок
|
||||||
|
// только из реально присутствующих полей (POI в радиусе 1 км).
|
||||||
const headlineSubtitle = data.score_explanation
|
const headlineSubtitle = data.score_explanation
|
||||||
? data.score_explanation
|
? data.score_explanation
|
||||||
: `Score +12 vs район · ${data.poi_count} POI в радиусе 1 км`;
|
: `${data.poi_count} POI в радиусе 1 км`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -299,20 +302,13 @@ export function Section1ParcelInfo({ cad }: Props) {
|
||||||
totalScore={poiData.poi_weighted_score}
|
totalScore={poiData.poi_weighted_score}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Fallback: build from analyze score_breakdown
|
// Fallback: B6 poi-score (useParcelPoiScoreQuery) ещё грузится или
|
||||||
<PoiList2Gis
|
// упал. score_breakdown несёт только name/distance_m — без реального
|
||||||
items={Object.entries(data.score_breakdown).flatMap(
|
// per-POI weight и без POI-weighted-score, поэтому НЕ подставляем
|
||||||
([cat, pois]) =>
|
// инвест-балл участка (data.score) как POI-оценку «X / 100» и НЕ
|
||||||
pois.slice(0, 2).map((poi) => ({
|
// фабрикуем weight/score_contribution (#1466). Показываем честный
|
||||||
category: cat,
|
// пустой стейт «POI данные недоступны» до прихода B6.
|
||||||
name: poi.name ?? cat,
|
<PoiList2Gis items={[]} totalScore={0} />
|
||||||
distance_m: poi.distance_m,
|
|
||||||
weight: 0.1,
|
|
||||||
score_contribution: 5,
|
|
||||||
})),
|
|
||||||
)}
|
|
||||||
totalScore={data.score}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface UtilityDisplayConfig {
|
||||||
|
|
||||||
function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
||||||
switch (subtype) {
|
switch (subtype) {
|
||||||
|
// ── Электричество ──
|
||||||
case "substation":
|
case "substation":
|
||||||
return {
|
return {
|
||||||
label: "Электричество (подстанция)",
|
label: "Электричество (подстанция)",
|
||||||
|
|
@ -43,6 +44,13 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
||||||
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
|
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
case "transformer":
|
||||||
|
return {
|
||||||
|
label: "Электричество (трансформатор)",
|
||||||
|
icon: (
|
||||||
|
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
|
||||||
|
),
|
||||||
|
};
|
||||||
case "power_line":
|
case "power_line":
|
||||||
return {
|
return {
|
||||||
label: "Электричество (ЛЭП)",
|
label: "Электричество (ЛЭП)",
|
||||||
|
|
@ -50,7 +58,8 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
||||||
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
|
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
case "pipeline":
|
// ── Газ ──
|
||||||
|
case "gas_pipeline":
|
||||||
return {
|
return {
|
||||||
label: "Газ (газопровод)",
|
label: "Газ (газопровод)",
|
||||||
icon: (
|
icon: (
|
||||||
|
|
@ -61,9 +70,21 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
case "water_intake":
|
case "pipeline":
|
||||||
return {
|
return {
|
||||||
label: "Водопровод",
|
label: "Трубопровод (без вещества)",
|
||||||
|
icon: (
|
||||||
|
<Flame
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--viz-4)" }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// ── Водопровод ──
|
||||||
|
case "water_main":
|
||||||
|
return {
|
||||||
|
label: "Водопровод (магистраль)",
|
||||||
icon: (
|
icon: (
|
||||||
<Droplet
|
<Droplet
|
||||||
size={16}
|
size={16}
|
||||||
|
|
@ -72,9 +93,43 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
case "pumping_station":
|
case "water_works":
|
||||||
return {
|
return {
|
||||||
label: "Канализация (насосная)",
|
label: "Водопровод (водозабор)",
|
||||||
|
icon: (
|
||||||
|
<Droplet
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--viz-2)" }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "water_tower":
|
||||||
|
return {
|
||||||
|
label: "Водопровод (водонапорная башня)",
|
||||||
|
icon: (
|
||||||
|
<Droplet
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--viz-2)" }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "storage_tank":
|
||||||
|
return {
|
||||||
|
label: "Резервуар",
|
||||||
|
icon: (
|
||||||
|
<Droplet
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--viz-2)" }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// ── Канализация ──
|
||||||
|
case "sewerage":
|
||||||
|
return {
|
||||||
|
label: "Канализация (коллектор)",
|
||||||
icon: (
|
icon: (
|
||||||
<Pipette
|
<Pipette
|
||||||
size={16}
|
size={16}
|
||||||
|
|
@ -83,6 +138,29 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
case "wastewater_plant":
|
||||||
|
return {
|
||||||
|
label: "Канализация (очистные)",
|
||||||
|
icon: (
|
||||||
|
<Pipette
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--fg-secondary)" }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// ── Теплоснабжение ──
|
||||||
|
case "heat_substation":
|
||||||
|
return {
|
||||||
|
label: "Теплоснабжение (ЦТП)",
|
||||||
|
icon: (
|
||||||
|
<Flame
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--viz-4)" }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: subtype,
|
label: subtype,
|
||||||
|
|
@ -135,14 +213,23 @@ function buildSubtitle(items: UtilitySummaryItem[]): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
const electric = items.find(
|
const electric = items.find(
|
||||||
(i) => i.subtype === "substation" || i.subtype === "power_line",
|
(i) =>
|
||||||
|
i.subtype === "substation" ||
|
||||||
|
i.subtype === "transformer" ||
|
||||||
|
i.subtype === "power_line",
|
||||||
|
);
|
||||||
|
const gas = items.find((i) => i.subtype === "gas_pipeline");
|
||||||
|
const water = items.find(
|
||||||
|
(i) =>
|
||||||
|
i.subtype === "water_main" ||
|
||||||
|
i.subtype === "water_works" ||
|
||||||
|
i.subtype === "water_tower",
|
||||||
);
|
);
|
||||||
const gas = items.find((i) => i.subtype === "pipeline");
|
|
||||||
const water = items.find((i) => i.subtype === "water_intake");
|
|
||||||
|
|
||||||
if (electric) parts.push(`электричество ${electric.nearest_m} м`);
|
if (electric)
|
||||||
if (gas) parts.push(`газ ${gas.nearest_m} м`);
|
parts.push(`электричество ${electric.nearest_m.toLocaleString("ru")} м`);
|
||||||
if (water) parts.push(`водопровод ${water.nearest_m} м`);
|
if (gas) parts.push(`газ ${gas.nearest_m.toLocaleString("ru")} м`);
|
||||||
|
if (water) parts.push(`водопровод ${water.nearest_m.toLocaleString("ru")} м`);
|
||||||
|
|
||||||
return parts.length > 0
|
return parts.length > 0
|
||||||
? parts.join(" · ")
|
? parts.join(" · ")
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ export interface CompareColumn {
|
||||||
score?: number | null;
|
score?: number | null;
|
||||||
scoreLabel?: string | null;
|
scoreLabel?: string | null;
|
||||||
velocityScore?: number | null; // 0..1
|
velocityScore?: number | null; // 0..1
|
||||||
|
// False → конкуренты есть, но данных velocity нет (velocity_score=0 — sentinel,
|
||||||
|
// не факт «рынок стоит»). Когда false, ячейка velocity = «—» и не участвует в
|
||||||
|
// подсветке «лучшее». undefined трактуется как «данные есть» (обратная совместимость).
|
||||||
|
velocityDataAvailable?: boolean | null;
|
||||||
medianPricePerM2?: number | null;
|
medianPricePerM2?: number | null;
|
||||||
pipeline24mo?: number | null; // total flats in 24-month pipeline
|
pipeline24mo?: number | null; // total flats in 24-month pipeline
|
||||||
confidenceLabel?: "high" | "medium" | "low" | null;
|
confidenceLabel?: "high" | "medium" | "low" | null;
|
||||||
|
|
@ -117,15 +121,16 @@ const METRIC_ROWS: MetricRow[] = [
|
||||||
key: "velocity",
|
key: "velocity",
|
||||||
label: "Скорость продаж",
|
label: "Скорость продаж",
|
||||||
winner: "high",
|
winner: "high",
|
||||||
value: (c) => c.velocityScore ?? null,
|
// velocity_score=0 при velocity_data_available=false — это «нет данных», а не
|
||||||
|
// реальный 0, поэтому такая колонка не должна участвовать в сравнении/подсветке.
|
||||||
|
value: (c) =>
|
||||||
|
c.velocityDataAvailable === false ? null : c.velocityScore ?? null,
|
||||||
render: (c) =>
|
render: (c) =>
|
||||||
c.velocityScore != null && Number.isFinite(c.velocityScore) ? (
|
c.velocityDataAvailable !== false &&
|
||||||
|
c.velocityScore != null &&
|
||||||
|
Number.isFinite(c.velocityScore) ? (
|
||||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{Math.round(c.velocityScore * 100)}
|
{Math.round(c.velocityScore * 100)}%
|
||||||
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
|
|
||||||
{" "}
|
|
||||||
/ 100
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
DASH
|
DASH
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ export function ParcelCompareLoader({ cad, onResult }: Props) {
|
||||||
score: a.score ?? null,
|
score: a.score ?? null,
|
||||||
scoreLabel: a.score_label ?? null,
|
scoreLabel: a.score_label ?? null,
|
||||||
velocityScore: a.velocity?.velocity_score ?? null,
|
velocityScore: a.velocity?.velocity_score ?? null,
|
||||||
|
// SF#17 / issue #1422: explicit false (конкуренты есть, данных Objective
|
||||||
|
// нет → velocity_score=0 — sentinel) гасит ячейку и исключает колонку из
|
||||||
|
// подсветки «лучшее». undefined/null трактуется как «данные есть».
|
||||||
|
velocityDataAvailable: a.velocity?.velocity_data_available ?? null,
|
||||||
medianPricePerM2: a.district?.median_price_per_m2 ?? null,
|
medianPricePerM2: a.district?.median_price_per_m2 ?? null,
|
||||||
pipeline24mo: a.pipeline_24mo?.flats_total ?? null,
|
pipeline24mo: a.pipeline_24mo?.flats_total ?? null,
|
||||||
confidenceLabel: a.confidence_label ?? null,
|
confidenceLabel: a.confidence_label ?? null,
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ function ParcelMarkers({
|
||||||
<div style={{ fontSize: 12 }}>
|
<div style={{ fontSize: 12 }}>
|
||||||
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
|
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
|
||||||
<div style={{ color: "var(--fg-secondary)" }}>
|
<div style={{ color: "var(--fg-secondary)" }}>
|
||||||
{parcel.area_ha.toFixed(2)} га · {statusLabel}
|
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
|
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
|
||||||
Нажмите, чтобы открыть анализ
|
Нажмите, чтобы открыть анализ
|
||||||
|
|
@ -129,7 +129,7 @@ function ParcelMarkers({
|
||||||
<div
|
<div
|
||||||
style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
|
style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
|
||||||
>
|
>
|
||||||
{parcel.area_ha.toFixed(2)} га · {statusLabel}
|
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -244,10 +244,15 @@ export function EntryMap({
|
||||||
)}
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
{/* Deselect on map click — overlay to capture clicks outside markers */}
|
{/* Deselect on map click — overlay to capture clicks outside markers.
|
||||||
|
zIndex must stay below ParcelDrawer (z20, sibling in .gd-map-shell):
|
||||||
|
neither this root nor .gd-map-shell creates a stacking context, so a
|
||||||
|
higher zIndex here would paint over the drawer and swallow its clicks
|
||||||
|
(X / "Открыть анализ" / ПКК link). z10 keeps the overlay above the
|
||||||
|
Leaflet map content but under the drawer card. */}
|
||||||
{selectedCad && (
|
{selectedCad && (
|
||||||
<div
|
<div
|
||||||
style={{ position: "absolute", inset: 0, zIndex: 399 }}
|
style={{ position: "absolute", inset: 0, zIndex: 10 }}
|
||||||
onClick={onParcelDeselect}
|
onClick={onParcelDeselect}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,16 @@ interface ChipProps {
|
||||||
label: string;
|
label: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Chip({ label, selected, onClick }: ChipProps) {
|
function Chip({ label, selected, onClick, disabled, title }: ChipProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
style={{
|
style={{
|
||||||
padding: "4px 12px",
|
padding: "4px 12px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
|
|
@ -34,7 +38,8 @@ function Chip({ label, selected, onClick }: ChipProps) {
|
||||||
color: selected ? "var(--accent)" : "var(--fg-secondary)",
|
color: selected ? "var(--accent)" : "var(--fg-secondary)",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: selected ? 500 : 400,
|
fontWeight: selected ? 500 : 400,
|
||||||
cursor: "pointer",
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||||||
}}
|
}}
|
||||||
|
|
@ -44,6 +49,15 @@ function Chip({ label, selected, onClick }: ChipProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// B1 by-bbox payload doesn't carry district/vri yet, and the endpoint ignores
|
||||||
|
// those query params — every parcel is adapted with placeholders
|
||||||
|
// (district:"—" / vri:"other"), so filtering on them either wipes the map or
|
||||||
|
// silently does nothing (#1475). Keep the controls visible but disabled until
|
||||||
|
// enrichment lands, so the UI doesn't pretend 2 of 3 filter axes work.
|
||||||
|
const DISTRICT_VRI_FILTERS_AVAILABLE = false;
|
||||||
|
const DISTRICT_VRI_DISABLED_HINT =
|
||||||
|
"Фильтр по району / ВРИ появится после обогащения данных участков";
|
||||||
|
|
||||||
const DISTRICTS = [
|
const DISTRICTS = [
|
||||||
"Ленинский",
|
"Ленинский",
|
||||||
"Верх-Исетский",
|
"Верх-Исетский",
|
||||||
|
|
@ -89,6 +103,7 @@ export function MapFilterBar({
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVri(v: ParcelVri) {
|
function toggleVri(v: ParcelVri) {
|
||||||
|
if (!DISTRICT_VRI_FILTERS_AVAILABLE) return;
|
||||||
onChange({ ...filters, vri: filters.vri === v ? undefined : v });
|
onChange({ ...filters, vri: filters.vri === v ? undefined : v });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,8 +122,8 @@ export function MapFilterBar({
|
||||||
|
|
||||||
const hasFilters =
|
const hasFilters =
|
||||||
filters.status != null ||
|
filters.status != null ||
|
||||||
filters.district != null ||
|
(DISTRICT_VRI_FILTERS_AVAILABLE && filters.district != null) ||
|
||||||
filters.vri != null ||
|
(DISTRICT_VRI_FILTERS_AVAILABLE && filters.vri != null) ||
|
||||||
filters.min_area != null ||
|
filters.min_area != null ||
|
||||||
filters.max_area != null;
|
filters.max_area != null;
|
||||||
|
|
||||||
|
|
@ -187,13 +202,19 @@ export function MapFilterBar({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* VRI chips */}
|
{/* VRI chips — disabled until B1 enrichment returns vri (#1475) */}
|
||||||
{VRI_OPTIONS.map((opt) => (
|
{VRI_OPTIONS.map((opt) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
selected={filters.vri === opt.value}
|
selected={
|
||||||
|
DISTRICT_VRI_FILTERS_AVAILABLE && filters.vri === opt.value
|
||||||
|
}
|
||||||
onClick={() => toggleVri(opt.value)}
|
onClick={() => toggleVri(opt.value)}
|
||||||
|
disabled={!DISTRICT_VRI_FILTERS_AVAILABLE}
|
||||||
|
title={
|
||||||
|
DISTRICT_VRI_FILTERS_AVAILABLE ? undefined : DISTRICT_VRI_DISABLED_HINT
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -207,9 +228,15 @@ export function MapFilterBar({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* District selector */}
|
{/* District selector — disabled until B1 enrichment returns district (#1475) */}
|
||||||
<select
|
<select
|
||||||
value={filters.district ?? ""}
|
value={DISTRICT_VRI_FILTERS_AVAILABLE ? (filters.district ?? "") : ""}
|
||||||
|
disabled={!DISTRICT_VRI_FILTERS_AVAILABLE}
|
||||||
|
title={
|
||||||
|
DISTRICT_VRI_FILTERS_AVAILABLE
|
||||||
|
? undefined
|
||||||
|
: DISTRICT_VRI_DISABLED_HINT
|
||||||
|
}
|
||||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||||
onChange({ ...filters, district: e.target.value || undefined })
|
onChange({ ...filters, district: e.target.value || undefined })
|
||||||
}
|
}
|
||||||
|
|
@ -217,14 +244,20 @@ export function MapFilterBar({
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: "4px 8px",
|
padding: "4px 8px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
border: filters.district
|
border:
|
||||||
? "1px solid var(--accent)"
|
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
|
||||||
: "1px solid var(--border-card)",
|
? "1px solid var(--accent)"
|
||||||
background: filters.district
|
: "1px solid var(--border-card)",
|
||||||
? "var(--accent-soft)"
|
background:
|
||||||
: "var(--bg-card)",
|
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
|
||||||
color: filters.district ? "var(--accent)" : "var(--fg-secondary)",
|
? "var(--accent-soft)"
|
||||||
cursor: "pointer",
|
: "var(--bg-card)",
|
||||||
|
color:
|
||||||
|
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--fg-secondary)",
|
||||||
|
cursor: DISTRICT_VRI_FILTERS_AVAILABLE ? "pointer" : "not-allowed",
|
||||||
|
opacity: DISTRICT_VRI_FILTERS_AVAILABLE ? 1 : 0.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Все районы</option>
|
<option value="">Все районы</option>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { X, MapPin, Maximize2, ArrowRight } from "lucide-react";
|
import { X, MapPin, Maximize2, ArrowRight } from "lucide-react";
|
||||||
import type { ParcelBboxItem } from "@/lib/site-finder-api";
|
import type { ParcelBboxItem } from "@/lib/site-finder-api";
|
||||||
|
import { addLocalRecentParcel } from "@/lib/site-finder-api";
|
||||||
import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend";
|
import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend";
|
||||||
|
|
||||||
interface ParcelDrawerProps {
|
interface ParcelDrawerProps {
|
||||||
|
|
@ -195,7 +196,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
|
||||||
fontVariantNumeric: "tabular-nums",
|
fontVariantNumeric: "tabular-nums",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{parcel.area_ha.toFixed(2)}{" "}
|
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"}{" "}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -321,6 +322,16 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`}
|
href={`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`}
|
||||||
|
onClick={() => {
|
||||||
|
// Record the visit so this entry path also lands in RecentParcels (#1477).
|
||||||
|
addLocalRecentParcel({
|
||||||
|
cad_num: parcel.cad_num,
|
||||||
|
address: parcel.address,
|
||||||
|
area_ha: parcel.area_ha,
|
||||||
|
district: parcel.district,
|
||||||
|
visited_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,26 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Clock, MapPin } from "lucide-react";
|
import { Clock, MapPin } from "lucide-react";
|
||||||
import { useRecentParcels } from "@/lib/site-finder-api";
|
import { useRecentParcels } from "@/lib/site-finder-api";
|
||||||
|
|
||||||
const MAX_SHOWN = 5;
|
const MAX_SHOWN = 5;
|
||||||
|
|
||||||
export function RecentParcels() {
|
export function RecentParcels() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: parcels, isLoading } = useRecentParcels();
|
const { data: parcels, isLoading } = useRecentParcels();
|
||||||
|
|
||||||
|
// The visit is written to localStorage in page.handleParcelOpen, but the
|
||||||
|
// QueryClient survives client-side navigation and useRecentParcels has a 60s
|
||||||
|
// staleTime, so returning to /site-finder otherwise shows a stale snapshot
|
||||||
|
// without the just-opened parcel. Invalidate on mount to refetch the latest
|
||||||
|
// localStorage state (the component re-mounts on each return to the page).
|
||||||
|
useEffect(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["recent-parcels"] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,12 @@ export function AnalogsTable({
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...rows].sort((a, b) => {
|
const sorted = [...rows].sort((a, b) => {
|
||||||
const av = a[sortKey] ?? 0;
|
const av = a[sortKey];
|
||||||
const bv = b[sortKey] ?? 0;
|
const bv = b[sortKey];
|
||||||
return sortAsc
|
// null ('неизвестно') всегда в конце, независимо от направления
|
||||||
? (av as number) - (bv as number)
|
if (av === null) return bv === null ? 0 : 1;
|
||||||
: (bv as number) - (av as number);
|
if (bv === null) return -1;
|
||||||
|
return sortAsc ? av - bv : bv - av;
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSort(key: SortKey) {
|
function handleSort(key: SortKey) {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,12 @@ interface Props {
|
||||||
export function EstimateResult({ estimate, input }: Props) {
|
export function EstimateResult({ estimate, input }: Props) {
|
||||||
const conf = CONFIDENCE_COLOR[estimate.confidence];
|
const conf = CONFIDENCE_COLOR[estimate.confidence];
|
||||||
|
|
||||||
|
// When restoring an estimate from a shared link (GET /estimate/{id}) the
|
||||||
|
// original object parameters are unknown — page.tsx passes an empty input
|
||||||
|
// (area_m2 = 0, total_floors = 0). Don't render the "Параметры объекта" block
|
||||||
|
// with placeholder garbage ("0 м²", "Студия", "Этаж 0 из 0") in that case.
|
||||||
|
const hasInputParams = Boolean(input.area_m2) || input.total_floors > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
{/* Hero card */}
|
{/* Hero card */}
|
||||||
|
|
@ -177,7 +183,7 @@ export function EstimateResult({ estimate, input }: Props) {
|
||||||
{conf.label}
|
{conf.label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: conf.fg }}>
|
<span style={{ fontSize: 11, color: conf.fg }}>
|
||||||
{estimate.n_analogs} аналог{ending(estimate.n_analogs)}
|
{estimate.n_analogs} объект{ending(estimate.n_analogs)} в выборке
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,98 +208,101 @@ export function EstimateResult({ estimate, input }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 1 — Cover / input snapshot */}
|
{/* Section 1 — Cover / input snapshot.
|
||||||
<Card>
|
Hidden when restoring from a shared link (params unknown). */}
|
||||||
<SectionHeader
|
{hasInputParams && (
|
||||||
icon={
|
<Card>
|
||||||
<svg
|
<SectionHeader
|
||||||
width="16"
|
icon={
|
||||||
height="16"
|
<svg
|
||||||
viewBox="0 0 24 24"
|
width="16"
|
||||||
fill="none"
|
height="16"
|
||||||
stroke="currentColor"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth="2"
|
fill="none"
|
||||||
strokeLinecap="round"
|
stroke="currentColor"
|
||||||
strokeLinejoin="round"
|
strokeWidth="2"
|
||||||
aria-hidden="true"
|
strokeLinecap="round"
|
||||||
>
|
strokeLinejoin="round"
|
||||||
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
|
aria-hidden="true"
|
||||||
<polyline points="16 7 22 7 22 13" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
title="Параметры объекта"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: "Адрес", value: input.address },
|
|
||||||
{ label: "Площадь", value: `${input.area_m2} м²` },
|
|
||||||
{
|
|
||||||
label: "Комнат",
|
|
||||||
value: input.rooms === 0 ? "Студия" : String(input.rooms),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Этаж",
|
|
||||||
value: `${input.floor} из ${input.total_floors}`,
|
|
||||||
},
|
|
||||||
...(input.year_built
|
|
||||||
? [{ label: "Год постройки", value: String(input.year_built) }]
|
|
||||||
: []),
|
|
||||||
...(input.house_type
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "Тип дома",
|
|
||||||
value:
|
|
||||||
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(input.repair_state
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "Ремонт",
|
|
||||||
value:
|
|
||||||
REPAIR_STATE_LABELS[input.repair_state] ??
|
|
||||||
input.repair_state,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
label: "Балкон",
|
|
||||||
value: input.has_balcony ? "Есть" : "Нет",
|
|
||||||
},
|
|
||||||
].map(({ label, value }) => (
|
|
||||||
<div key={label}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: "#9ca3af",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
marginBottom: 2,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{label}
|
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
|
||||||
|
<polyline points="16 7 22 7 22 13" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
title="Параметры объекта"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: "Адрес", value: input.address },
|
||||||
|
{ label: "Площадь", value: `${input.area_m2} м²` },
|
||||||
|
{
|
||||||
|
label: "Комнат",
|
||||||
|
value: input.rooms === 0 ? "Студия" : String(input.rooms),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Этаж",
|
||||||
|
value: `${input.floor} из ${input.total_floors}`,
|
||||||
|
},
|
||||||
|
...(input.year_built
|
||||||
|
? [{ label: "Год постройки", value: String(input.year_built) }]
|
||||||
|
: []),
|
||||||
|
...(input.house_type
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Тип дома",
|
||||||
|
value:
|
||||||
|
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(input.repair_state
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Ремонт",
|
||||||
|
value:
|
||||||
|
REPAIR_STATE_LABELS[input.repair_state] ??
|
||||||
|
input.repair_state,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: "Балкон",
|
||||||
|
value: input.has_balcony ? "Есть" : "Нет",
|
||||||
|
},
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#9ca3af",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#1a1d23",
|
||||||
|
fontWeight: 500,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
))}
|
||||||
style={{
|
</div>
|
||||||
fontSize: 13,
|
</Card>
|
||||||
color: "#1a1d23",
|
)}
|
||||||
fontWeight: 500,
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Section 2 — Listings (analogs) */}
|
{/* Section 2 — Listings (analogs) */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
|
||||||
});
|
});
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
} else {
|
} else {
|
||||||
if (previousFocusRef.current instanceof HTMLElement) {
|
if (
|
||||||
|
previousFocusRef.current instanceof HTMLElement &&
|
||||||
|
document.contains(previousFocusRef.current)
|
||||||
|
) {
|
||||||
previousFocusRef.current.focus();
|
previousFocusRef.current.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +131,7 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: "100%",
|
width: resolvedWidth,
|
||||||
maxHeight: "85vh",
|
maxHeight: "85vh",
|
||||||
background: "var(--bg-card)",
|
background: "var(--bg-card)",
|
||||||
boxShadow: "0 -4px 32px rgba(0,0,0,0.14)",
|
boxShadow: "0 -4px 32px rgba(0,0,0,0.14)",
|
||||||
|
|
@ -141,32 +144,20 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!open) {
|
// Keep the same DOM tree across open/closed so the panel element stays
|
||||||
// Keep in DOM for transition but aria-hide
|
// mounted and the transform transition can interpolate on open→close.
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
zIndex: 199,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={drawerRef} style={panelStyle} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-hidden={open ? undefined : "true"}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: "rgba(0,0,0,0.32)",
|
background: open ? "rgba(0,0,0,0.32)" : "transparent",
|
||||||
|
pointerEvents: open ? "auto" : "none",
|
||||||
|
transition: "background 180ms ease",
|
||||||
zIndex: 199,
|
zIndex: 199,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: side === "bottom" ? "flex-end" : "stretch",
|
alignItems: side === "bottom" ? "flex-end" : "stretch",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,11 @@ function sessionHeaders(): Record<string, string> {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function customPoisKey(parcelCad?: string | null): unknown[] {
|
function customPoisKey(parcelCad?: string | null): unknown[] {
|
||||||
return ["custom-pois", parcelCad ?? null];
|
// Scope the cache by session: the list is filtered server-side via the
|
||||||
|
// X-Session-Id header (sessionHeaders()), so a session change must yield a
|
||||||
|
// distinct key to avoid serving a previous session's POIs (issue #1484).
|
||||||
|
// getOrCreateSessionId() is SSR-safe (returns "" when window is absent).
|
||||||
|
return ["custom-pois", getOrCreateSessionId(), parcelCad ?? null];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Canonical analyze key prefix is ["parcel-analyze", cad, horizon] (see
|
// Canonical analyze key prefix is ["parcel-analyze", cad, horizon] (see
|
||||||
|
|
|
||||||
|
|
@ -158,12 +158,20 @@ export function useSiteAnalysis() {
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
if (status.status === "ready") {
|
if (status.status === "ready") {
|
||||||
// Re-trigger analyze — should be 200 now.
|
// Re-trigger analyze — should be 200 now. Используем
|
||||||
|
// apiFetchWithStatus, чтобы НЕ принять 202-stub за готовый
|
||||||
|
// ParcelAnalysis (#1339): re-POST может вернуть 202 Accepted
|
||||||
|
// (геометрия ready, но analyze-воркер не закончил —
|
||||||
|
// задокументированная гонка). 202-stub не имеет score/
|
||||||
|
// score_breakdown → крашит рендер Секций 3/4. На 202 продолжаем
|
||||||
|
// polling (symmetry с первым запросом), как в useParcelAnalyzeQuery.
|
||||||
// setFetchingState(null) ПОСЛЕ await чтобы не было flicker:
|
// setFetchingState(null) ПОСЛЕ await чтобы не было flicker:
|
||||||
// если очистить до await, mutation isPending=true но
|
// если очистить до await, mutation isPending=true но
|
||||||
// fetchingState=null → пустой экран ~1 RTT. После await
|
// fetchingState=null → пустой экран ~1 RTT. После await
|
||||||
// mutation сразу резолвится с data — render skipped.
|
// mutation сразу резолвится с data — render skipped.
|
||||||
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
const second = await apiFetchWithStatus<
|
||||||
|
ParcelAnalysis | AnalyzeAcceptedResponse
|
||||||
|
>(analyzeUrl(cad), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
...(bodyPayload
|
...(bodyPayload
|
||||||
|
|
@ -173,12 +181,16 @@ export function useSiteAnalysis() {
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
// Только если этот вызов всё ещё актуален — иначе зомби-цикл
|
if (second.status === 200) {
|
||||||
// стирает баннер свежей мутации (#1242).
|
// Только если этот вызов всё ещё актуален — иначе зомби-цикл
|
||||||
if (!signal.aborted) {
|
// стирает баннер свежей мутации (#1242).
|
||||||
setFetchingState(null);
|
if (!signal.aborted) {
|
||||||
|
setFetchingState(null);
|
||||||
|
}
|
||||||
|
return second.body as ParcelAnalysis;
|
||||||
}
|
}
|
||||||
return second;
|
// 202 race → fall through and poll /fetch-status again.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (status.status === "not_in_nspd") {
|
if (status.status === "not_in_nspd") {
|
||||||
if (!signal.aborted) setFetchingState(null);
|
if (!signal.aborted) setFetchingState(null);
|
||||||
|
|
@ -204,7 +216,16 @@ export function useSiteAnalysis() {
|
||||||
status.error_msg ?? "Неверный формат кадастрового номера",
|
status.error_msg ?? "Неверный формат кадастрового номера",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// status === "fetching" → continue polling
|
if (status.status === "fetching") {
|
||||||
|
// continue polling
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Неизвестный status (#1485): рассинхрон фронт/бэк — опечатка или
|
||||||
|
// новый/переименованный статус вне закрытого union FetchStatus.
|
||||||
|
// Бросаем явную ошибку вместо молчаливого polling до 2-мин таймаута
|
||||||
|
// (что вводило бы в заблуждение «загрузка слишком долгая»).
|
||||||
|
if (!signal.aborted) setFetchingState(null);
|
||||||
|
throw new Error(`Неизвестный статус загрузки: ${status.status}`);
|
||||||
}
|
}
|
||||||
// Polling exhausted (2 min)
|
// Polling exhausted (2 min)
|
||||||
if (!signal.aborted) setFetchingState(null);
|
if (!signal.aborted) setFetchingState(null);
|
||||||
|
|
|
||||||
|
|
@ -2932,6 +2932,11 @@ export interface components {
|
||||||
* @default rosreestr_pending
|
* @default rosreestr_pending
|
||||||
*/
|
*/
|
||||||
source: string;
|
source: string;
|
||||||
|
/**
|
||||||
|
* Rate Ms
|
||||||
|
* @default 600
|
||||||
|
*/
|
||||||
|
rate_ms: number;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* ChatAskRequest
|
* ChatAskRequest
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ function profilesHeaders(adminToken: string): HeadersInit {
|
||||||
/** List all weight profiles for a given user_id. */
|
/** List all weight profiles for a given user_id. */
|
||||||
export function useWeightProfiles(userId: string, adminToken: string) {
|
export function useWeightProfiles(userId: string, adminToken: string) {
|
||||||
return useQuery<WeightProfile[]>({
|
return useQuery<WeightProfile[]>({
|
||||||
queryKey: ["weight-profiles", userId, adminToken],
|
queryKey: ["weight-profiles", userId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<WeightProfile[]>(
|
apiFetch<WeightProfile[]>(
|
||||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
||||||
|
|
@ -153,7 +153,7 @@ export function useUpdateProfile(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a weight profile by id. Returns true if deleted. */
|
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
||||||
export function useDeleteProfile(userId: string, adminToken: string) {
|
export function useDeleteProfile(userId: string, adminToken: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<void, Error, number>({
|
return useMutation<void, Error, number>({
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ export function triggerDownload(blob: Blob, filename: string): void {
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
// `a.click()` only queues the download — the browser still needs to read the
|
||||||
URL.revokeObjectURL(url);
|
// blob via the object URL after this handler returns. Revoking synchronously
|
||||||
|
// frees the blob too early and intermittently cancels the download in Firefox
|
||||||
|
// and Safari, so defer cleanup to a later tick.
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ export type ParcelVri =
|
||||||
export interface ParcelBboxItem {
|
export interface ParcelBboxItem {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
address: string;
|
address: string;
|
||||||
area_ha: number;
|
// #1421: nullable — the B1 by-bbox payload may omit area_m2; null означает
|
||||||
|
// «н/д», в отличие от реального 0 га. Потребители обязаны гейтить null.
|
||||||
|
area_ha: number | null;
|
||||||
status: ParcelStatus;
|
status: ParcelStatus;
|
||||||
district: string;
|
district: string;
|
||||||
vri: ParcelVri;
|
vri: ParcelVri;
|
||||||
|
|
@ -77,7 +79,8 @@ export interface BboxCoords {
|
||||||
export interface RecentParcel {
|
export interface RecentParcel {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
address: string;
|
address: string;
|
||||||
area_ha: number;
|
// #1421: nullable — площадь может быть неизвестна; null → «н/д», не «0 га».
|
||||||
|
area_ha: number | null;
|
||||||
district: string;
|
district: string;
|
||||||
visited_at: string; // ISO string
|
visited_at: string; // ISO string
|
||||||
}
|
}
|
||||||
|
|
@ -121,8 +124,20 @@ function applyFilters(
|
||||||
filters: ParcelBboxFilters,
|
filters: ParcelBboxFilters,
|
||||||
): ParcelBboxItem[] {
|
): ParcelBboxItem[] {
|
||||||
return parcels.filter((p) => {
|
return parcels.filter((p) => {
|
||||||
if (filters.min_area != null && p.area_ha < filters.min_area) return false;
|
// #1421: area_ha теперь nullable («н/д»). Площадной фильтр применяем только
|
||||||
if (filters.max_area != null && p.area_ha > filters.max_area) return false;
|
// к участкам с известной площадью; null проходит мимо min/max гейтов.
|
||||||
|
if (
|
||||||
|
filters.min_area != null &&
|
||||||
|
p.area_ha != null &&
|
||||||
|
p.area_ha < filters.min_area
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
filters.max_area != null &&
|
||||||
|
p.area_ha != null &&
|
||||||
|
p.area_ha > filters.max_area
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (filters.status != null && p.status !== filters.status) return false;
|
if (filters.status != null && p.status !== filters.status) return false;
|
||||||
if (
|
if (
|
||||||
filters.district != null &&
|
filters.district != null &&
|
||||||
|
|
@ -209,17 +224,33 @@ export function useParcelsBboxQuery(
|
||||||
cad_num: p.cad_num,
|
cad_num: p.cad_num,
|
||||||
lat: p.centroid_lat,
|
lat: p.centroid_lat,
|
||||||
lon: p.centroid_lon,
|
lon: p.centroid_lon,
|
||||||
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0,
|
// #1421: keep null when area_m2 is missing — не приземляем в 0, иначе
|
||||||
|
// карточка показывает «0 га» вместо «н/д».
|
||||||
|
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : null,
|
||||||
status: p.status ?? "free",
|
status: p.status ?? "free",
|
||||||
district: "—",
|
district: "—",
|
||||||
vri: "other",
|
vri: "other",
|
||||||
address: "",
|
address: "",
|
||||||
}));
|
}));
|
||||||
// Apply filters client-side until backend supports them (B1 follow-up)
|
// Apply filters client-side until backend supports them (B1 follow-up).
|
||||||
return applyFilters(adapted, filters);
|
// district/vri are NOT in the B1 by-bbox payload — every adapted parcel
|
||||||
|
// carries the placeholders district:"—" / vri:"other". Filtering on those
|
||||||
|
// client-side would wipe the whole map the moment the user picks any real
|
||||||
|
// district or VRI (none of the UI values equal "—"/"other"), so omit those
|
||||||
|
// two filter keys here. Площадь/статус — реальные данные, фильтруем их.
|
||||||
|
const areaStatusFilters: ParcelBboxFilters = {
|
||||||
|
min_area: filters.min_area,
|
||||||
|
max_area: filters.max_area,
|
||||||
|
status: filters.status,
|
||||||
|
};
|
||||||
|
return applyFilters(adapted, areaStatusFilters);
|
||||||
},
|
},
|
||||||
enabled: MOCK_PARCELS_BBOX || bbox != null,
|
enabled: MOCK_PARCELS_BBOX || bbox != null,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
// #1419: bbox is part of the queryKey, so every pan/zoom is a fresh cache
|
||||||
|
// entry and `data` would blank to `undefined` mid-refetch → KPI flickers to
|
||||||
|
// «0 / 0». Hold the previous parcels on screen while the new bbox refetches.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
"""
|
"""
|
||||||
Claude Code PostToolUse hook — blocks new `print(` calls in backend/**/*.py.
|
Claude Code PostToolUse hook — blocks new `print(` calls in backend/**/*.py.
|
||||||
|
|
||||||
Wired in `.claude/settings.json` hooks.PostToolUse with matcher "Edit|Write".
|
Wired in `.claude/settings.json` hooks.PostToolUse with matcher
|
||||||
|
"Edit|Write|MultiEdit".
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- Reads hook input as JSON from stdin (per Anthropic spec).
|
- Reads hook input as JSON from stdin (per Anthropic spec).
|
||||||
- If tool is Edit/Write and file_path matches backend/**/*.py:
|
- If tool is Edit/Write/MultiEdit and file_path matches backend/**/*.py:
|
||||||
- Scans new content for new `print(` calls.
|
- Scans new content for new `print(` calls.
|
||||||
- Exits with code 2 to block (stderr is forwarded to Claude as feedback).
|
- Exits with code 2 to block (stderr is forwarded to Claude as feedback).
|
||||||
- Otherwise exits 0 (no-op).
|
- Otherwise exits 0 (no-op).
|
||||||
|
|
@ -29,15 +30,23 @@ ALLOW_PATH_PREFIXES = (
|
||||||
"backend/scripts/",
|
"backend/scripts/",
|
||||||
)
|
)
|
||||||
WATCH_PATH_PREFIX = "backend/"
|
WATCH_PATH_PREFIX = "backend/"
|
||||||
PRINT_RE = re.compile(r"(?<!\w)print\s*\(")
|
# Lookbehind `(?<![\w.])` rejects both word-char prefixes (e.g. `endprint(`)
|
||||||
|
# and method calls (e.g. `self.print(`, `logger.print(`), which are not the
|
||||||
|
# builtin `print`.
|
||||||
|
PRINT_RE = re.compile(r"(?<![\w.])print\s*\(")
|
||||||
|
|
||||||
|
|
||||||
def normalize_path(raw: str) -> str:
|
def normalize_path(raw: str) -> str:
|
||||||
"""Convert absolute/relative path to repo-relative POSIX-like form."""
|
"""Convert absolute/relative path to repo-relative POSIX-like form."""
|
||||||
p = Path(raw).as_posix()
|
parts = Path(raw).parts
|
||||||
# Try to strip everything up to the last "backend/" occurrence.
|
# Match the repo-root segment "backend" exactly (not a substring like
|
||||||
idx = p.rfind("backend/")
|
# "services-backend"), and anchor on the FIRST such segment so a nested
|
||||||
return p[idx:] if idx >= 0 else p
|
# "backend" dir does not drop the leading path components.
|
||||||
|
try:
|
||||||
|
idx = parts.index("backend")
|
||||||
|
except ValueError:
|
||||||
|
return Path(raw).as_posix()
|
||||||
|
return Path(*parts[idx:]).as_posix()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|
@ -48,7 +57,7 @@ def main() -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
tool_name = payload.get("tool_name") or payload.get("tool") or ""
|
tool_name = payload.get("tool_name") or payload.get("tool") or ""
|
||||||
if tool_name not in {"Edit", "Write"}:
|
if tool_name not in {"Edit", "Write", "MultiEdit"}:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
tool_input = payload.get("tool_input") or {}
|
tool_input = payload.get("tool_input") or {}
|
||||||
|
|
@ -64,8 +73,17 @@ def main() -> int:
|
||||||
if not norm.endswith(".py"):
|
if not norm.endswith(".py"):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Pull the new content. Edit gives `new_string`, Write gives `content`.
|
# Pull the new content. Edit gives `new_string`, Write gives `content`,
|
||||||
new_text = tool_input.get("new_string") or tool_input.get("content") or ""
|
# MultiEdit gives an `edits` array of `{old_string, new_string}` entries.
|
||||||
|
edits = tool_input.get("edits")
|
||||||
|
if isinstance(edits, list):
|
||||||
|
new_text = "\n".join(
|
||||||
|
str(e.get("new_string") or "")
|
||||||
|
for e in edits
|
||||||
|
if isinstance(e, dict)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_text = tool_input.get("new_string") or tool_input.get("content") or ""
|
||||||
if not new_text:
|
if not new_text:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
@ -73,6 +91,10 @@ def main() -> int:
|
||||||
for lineno, line in enumerate(new_text.splitlines(), start=1):
|
for lineno, line in enumerate(new_text.splitlines(), start=1):
|
||||||
if "# noqa" in line:
|
if "# noqa" in line:
|
||||||
continue
|
continue
|
||||||
|
# Skip whole-line comments (e.g. `# print(debug)`); regex on raw
|
||||||
|
# lines cannot see the surrounding syntax otherwise.
|
||||||
|
if line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
if PRINT_RE.search(line):
|
if PRINT_RE.search(line):
|
||||||
offenders.append((lineno, line.strip()))
|
offenders.append((lineno, line.strip()))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ def fetch_all_prs() -> dict[str, dict]:
|
||||||
page = 1
|
page = 1
|
||||||
api = f"{FORGEJO_URL}/api/v1/repos/{OWNER}/{REPO_NAME}/pulls"
|
api = f"{FORGEJO_URL}/api/v1/repos/{OWNER}/{REPO_NAME}/pulls"
|
||||||
|
|
||||||
while page <= 30:
|
while True:
|
||||||
url = f"{api}?state=all&limit=50&page={page}&sort=newest"
|
url = f"{api}?state=all&limit=50&page={page}&sort=newest"
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, headers={"Authorization": f"token {TOKEN}"}
|
url, headers={"Authorization": f"token {TOKEN}"}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,33 @@ def main() -> None:
|
||||||
local.add(rel)
|
local.add(rel)
|
||||||
print(f"Local files in target dirs: {len(local)}")
|
print(f"Local files in target dirs: {len(local)}")
|
||||||
|
|
||||||
# 2. Fetch CouchDB docs
|
# 2. Fetch ALL CouchDB docs (paginated — _all_docs усекается limit'ом,
|
||||||
r = requests.get(f"{BASE}/_all_docs", auth=AUTH,
|
# а неполный alive-набор приведёт к удалению живых chunks как orphan).
|
||||||
params={"limit": 10000}, timeout=60)
|
# Идём постранично через startkey_docid: last id страницы становится
|
||||||
rows = r.json()["rows"]
|
# startkey следующей, дублирующая первая строка отбрасывается. Цикл
|
||||||
|
# завершается, когда страница вернула меньше page_size строк.
|
||||||
|
page_size = 10000
|
||||||
|
rows: list[dict] = []
|
||||||
|
startkey_docid: str | None = None
|
||||||
|
while True:
|
||||||
|
params: dict[str, object] = {"limit": page_size}
|
||||||
|
if startkey_docid is not None:
|
||||||
|
# startkey должен быть JSON-строкой
|
||||||
|
params["startkey"] = f'"{startkey_docid}"'
|
||||||
|
params["startkey_docid"] = startkey_docid
|
||||||
|
r = requests.get(f"{BASE}/_all_docs", auth=AUTH,
|
||||||
|
params=params, timeout=60)
|
||||||
|
page = r.json()["rows"]
|
||||||
|
if startkey_docid is not None and page and page[0]["id"] == startkey_docid:
|
||||||
|
# первая строка дублирует startkey предыдущей страницы — отбрасываем
|
||||||
|
page = page[1:]
|
||||||
|
rows.extend(page)
|
||||||
|
# Полная страница (page_size до отбрасывания дубля) => возможно есть ещё.
|
||||||
|
# Если строк меньше — это последняя страница.
|
||||||
|
if len(page) + (1 if startkey_docid is not None else 0) < page_size:
|
||||||
|
break
|
||||||
|
startkey_docid = rows[-1]["id"]
|
||||||
|
print(f"CouchDB docs fetched (paginated): {len(rows)}")
|
||||||
|
|
||||||
# 3. Identify ghosts
|
# 3. Identify ghosts
|
||||||
ghosts: list[tuple[str, str]] = []
|
ghosts: list[tuple[str, str]] = []
|
||||||
|
|
|
||||||
|
|
@ -84,13 +84,17 @@ _MONTH_MAP = {
|
||||||
}
|
}
|
||||||
_DATE_PATTERNS = [
|
_DATE_PATTERNS = [
|
||||||
# Apr27_2026, Apr30_2026, Apr29_2026_Late
|
# Apr27_2026, Apr30_2026, Apr29_2026_Late
|
||||||
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})_(?P<y>20\d{2})\b", re.IGNORECASE),
|
# Ведущий lookbehind (?<![A-Za-z]) вместо \b: в именах entity месяц следует
|
||||||
|
# за '_' (Session_End_Apr30_2026), а '_' — это \w, поэтому \b между '_' и
|
||||||
|
# буквой не возникает. Завершающий (?![\dA-Za-z]) вместо \b — чтобы суффикс
|
||||||
|
# '_Late' (Apr29_2026_Late) не срывал совпадение.
|
||||||
|
re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})_(?P<y>20\d{2})(?![\dA-Za-z])", re.IGNORECASE),
|
||||||
# Apr26 (year implied 2026)
|
# Apr26 (year implied 2026)
|
||||||
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})\b(?!_20)", re.IGNORECASE),
|
re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})(?![\dA-Za-z])(?!_20)", re.IGNORECASE),
|
||||||
# Nov2025
|
# Nov2025
|
||||||
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<y>20\d{2})\b", re.IGNORECASE),
|
re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<y>20\d{2})(?![\dA-Za-z])", re.IGNORECASE),
|
||||||
# Q1_2026, Q2_2026 — quarter→month
|
# Q1_2026, Q2_2026 — quarter→month
|
||||||
re.compile(r"\bQ(?P<q>[1-4])_(?P<y>20\d{2})\b"),
|
re.compile(r"(?<![A-Za-z])Q(?P<q>[1-4])_(?P<y>20\d{2})(?![\dA-Za-z])"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,42 @@ OUT.mkdir(exist_ok=True)
|
||||||
|
|
||||||
PARCEL_ID = "parcel:66:41:0204016:10"
|
PARCEL_ID = "parcel:66:41:0204016:10"
|
||||||
|
|
||||||
|
# Source of truth for scoring weights (mirrors 03_score.py WEIGHTS / scoring_weights table).
|
||||||
|
# economic=0.30 is the heaviest component — must be reported, not hidden.
|
||||||
|
DEFAULT_WEIGHTS = {
|
||||||
|
"education": 0.20,
|
||||||
|
"health": 0.10,
|
||||||
|
"retail": 0.15,
|
||||||
|
"transit": 0.15,
|
||||||
|
"leisure": 0.10,
|
||||||
|
"economic": 0.30,
|
||||||
|
}
|
||||||
|
|
||||||
|
WEIGHT_LABELS = {
|
||||||
|
"education": "образование",
|
||||||
|
"health": "здоровье",
|
||||||
|
"retail": "ритейл",
|
||||||
|
"transit": "транспорт",
|
||||||
|
"leisure": "досуг",
|
||||||
|
"economic": "экономика",
|
||||||
|
"market": "рынок",
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_weights(conn):
|
||||||
|
"""Single source of truth for weights used in both JSON and HTML.
|
||||||
|
|
||||||
|
Prefer the scoring_weights table (written by 10_score_v2.py); fall back to
|
||||||
|
DEFAULT_WEIGHTS (== 03_score.py WEIGHTS). Includes economic (and market for v2).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = conn.execute("SELECT component, weight FROM scoring_weights").fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
rows = None
|
||||||
|
if rows:
|
||||||
|
return {c: float(w) for c, w in rows}
|
||||||
|
return dict(DEFAULT_WEIGHTS)
|
||||||
|
|
||||||
|
|
||||||
def fetch_economics(conn, site_id):
|
def fetch_economics(conn, site_id):
|
||||||
row = conn.execute("""SELECT sd.district, sd.method, sd.nearest_jk_dist_m,
|
row = conn.execute("""SELECT sd.district, sd.method, sd.nearest_jk_dist_m,
|
||||||
de.n_projects, de.weighted_price_m2, de.median_price_m2,
|
de.n_projects, de.weighted_price_m2, de.median_price_m2,
|
||||||
|
|
@ -65,6 +101,7 @@ def main():
|
||||||
conn = sqlite3.connect(DB)
|
conn = sqlite3.connect(DB)
|
||||||
parcel = fetch_site_full(conn, PARCEL_ID)
|
parcel = fetch_site_full(conn, PARCEL_ID)
|
||||||
n_total = conn.execute("SELECT count(*) FROM sites").fetchone()[0]
|
n_total = conn.execute("SELECT count(*) FROM sites").fetchone()[0]
|
||||||
|
weights = load_weights(conn)
|
||||||
|
|
||||||
# Top-10 best-scoring ЖК + parcel position context
|
# Top-10 best-scoring ЖК + parcel position context
|
||||||
rows = conn.execute("""SELECT s.site_id, s.name, s.district, s.developer, s.obj_class,
|
rows = conn.execute("""SELECT s.site_id, s.name, s.district, s.developer, s.obj_class,
|
||||||
|
|
@ -126,7 +163,7 @@ def main():
|
||||||
"n_compared_jk": stats["n_jk"],
|
"n_compared_jk": stats["n_jk"],
|
||||||
"weighted_score_distribution": stats,
|
"weighted_score_distribution": stats,
|
||||||
"component_comparison": comp_stats,
|
"component_comparison": comp_stats,
|
||||||
"weights_used": {"education":0.30,"health":0.15,"retail":0.20,"transit":0.20,"leisure":0.15},
|
"weights_used": weights,
|
||||||
"top10_best_jk_ekb": top10,
|
"top10_best_jk_ekb": top10,
|
||||||
"10_closest_jk_to_parcel": closest,
|
"10_closest_jk_to_parcel": closest,
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +206,11 @@ def econ_block(e):
|
||||||
def build_html(d):
|
def build_html(d):
|
||||||
p = d["parcel"]
|
p = d["parcel"]
|
||||||
cs = d["component_comparison"]
|
cs = d["component_comparison"]
|
||||||
|
# Single source of weights for the caption — identical to JSON weights_used.
|
||||||
|
weights_caption = " · ".join(
|
||||||
|
f"{WEIGHT_LABELS.get(c, c)} {w*100:.0f}%"
|
||||||
|
for c, w in d.get("weights_used", {}).items()
|
||||||
|
)
|
||||||
poi_table_rows = []
|
poi_table_rows = []
|
||||||
for cat, items in p["nearest_pois"].items():
|
for cat, items in p["nearest_pois"].items():
|
||||||
nearest = items[0] if items else None
|
nearest = items[0] if items else None
|
||||||
|
|
@ -183,7 +225,7 @@ def build_html(d):
|
||||||
comp_rows = []
|
comp_rows = []
|
||||||
for c, v in cs.items():
|
for c, v in cs.items():
|
||||||
delta = v["parcel"] - v["median_jk"]
|
delta = v["parcel"] - v["median_jk"]
|
||||||
cls = "g" if delta > 0 else ("r" if delta < 0 else "")
|
cls = "g" if delta > 0 else ("neg" if delta < 0 else "")
|
||||||
comp_rows.append(
|
comp_rows.append(
|
||||||
f"<tr><td>{c}</td><td class='r'>{v['parcel']}</td>"
|
f"<tr><td>{c}</td><td class='r'>{v['parcel']}</td>"
|
||||||
f"<td class='r'>{v['median_jk']}</td><td class='r'>{v['p75_jk']}</td>"
|
f"<td class='r'>{v['median_jk']}</td><td class='r'>{v['p75_jk']}</td>"
|
||||||
|
|
@ -222,7 +264,7 @@ th,td{{border:1px solid #ddd;padding:6px 10px;text-align:left}}
|
||||||
th{{background:#f5f7fa;font-weight:600}}
|
th{{background:#f5f7fa;font-weight:600}}
|
||||||
.r{{text-align:right}}
|
.r{{text-align:right}}
|
||||||
.g{{color:#0a6;font-weight:600}}
|
.g{{color:#0a6;font-weight:600}}
|
||||||
.r.r{{color:#c33;font-weight:600}}
|
.neg{{color:#c33;font-weight:600}}
|
||||||
.muted{{color:#888;font-size:12px}}
|
.muted{{color:#888;font-size:12px}}
|
||||||
.note{{background:#fffbe6;border-left:4px solid #f0c000;padding:10px 14px;border-radius:4px;margin:12px 0}}
|
.note{{background:#fffbe6;border-left:4px solid #f0c000;padding:10px 14px;border-radius:4px;margin:12px 0}}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
|
|
@ -245,9 +287,9 @@ th{{background:#f5f7fa;font-weight:600}}
|
||||||
<h2>Компоненты (взвешенные)</h2>
|
<h2>Компоненты (взвешенные)</h2>
|
||||||
<table><thead><tr><th>Компонент</th><th class=r>Участок</th><th class=r>Медиана ЖК</th><th class=r>P75 ЖК</th><th class=r>Δ vs медиана</th></tr></thead>
|
<table><thead><tr><th>Компонент</th><th class=r>Участок</th><th class=r>Медиана ЖК</th><th class=r>P75 ЖК</th><th class=r>Δ vs медиана</th></tr></thead>
|
||||||
<tbody>{"".join(comp_rows)}</tbody></table>
|
<tbody>{"".join(comp_rows)}</tbody></table>
|
||||||
<div class=muted>Веса: образование 20% · здоровье 10% · ритейл 15% · транспорт 15% · досуг 10% · экономика 30%</div>
|
<div class=muted>Веса: {weights_caption}</div>
|
||||||
|
|
||||||
<h2>Экономика района (Объектив API, последние 90 дней)</h2>
|
<h2>Экономика района (Объектив API; скорость — за 12 мес, доли продаж и цены — накопительно за всё время)</h2>
|
||||||
{econ_block(p.get("economics"))}
|
{econ_block(p.get("economics"))}
|
||||||
|
|
||||||
<h2>Ближайшие POI вокруг участка</h2>
|
<h2>Ближайшие POI вокруг участка</h2>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ def main():
|
||||||
conn = sqlite3.connect(DB)
|
conn = sqlite3.connect(DB)
|
||||||
sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall()
|
sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall()
|
||||||
lats=[s[1] for s in sites]; lons=[s[2] for s in sites]
|
lats=[s[1] for s in sites]; lons=[s[2] for s in sites]
|
||||||
b = (min(lats)-0.005, min(lons)-0.005, max(lats)+0.005, max(lons)+0.005)
|
b = (min(lats)-0.05, min(lons)-0.05, max(lats)+0.05, max(lons)+0.05)
|
||||||
|
|
||||||
for cat, filt in NEW_QUERIES:
|
for cat, filt in NEW_QUERIES:
|
||||||
if cat in cache:
|
if cat in cache:
|
||||||
|
|
|
||||||
|
|
@ -609,10 +609,10 @@ def jk_full(site_id: str):
|
||||||
obj_id = s.get("obj_id")
|
obj_id = s.get("obj_id")
|
||||||
if obj_id:
|
if obj_id:
|
||||||
try:
|
try:
|
||||||
import psycopg2
|
import psycopg
|
||||||
pg = psycopg2.connect(host="127.0.0.1", port=15432, user="gendesign",
|
pg = psycopg.connect(host="127.0.0.1", port=15432, user="gendesign",
|
||||||
password="2J2SBPMKuS998fiwhtQqDhMI",
|
password="2J2SBPMKuS998fiwhtQqDhMI",
|
||||||
dbname="gendesign", connect_timeout=2)
|
dbname="gendesign", connect_timeout=2)
|
||||||
pcur = pg.cursor()
|
pcur = pg.cursor()
|
||||||
pcur.execute("""SELECT photo_url, photo_dttm, period_dt, photo_name, ready_desc, thumb_path, hidden
|
pcur.execute("""SELECT photo_url, photo_dttm, period_dt, photo_name, ready_desc, thumb_path, hidden
|
||||||
FROM domrf_kn_photos
|
FROM domrf_kn_photos
|
||||||
|
|
@ -844,10 +844,13 @@ def developer_track_record(developer_name: str):
|
||||||
avg_score = sum(o["weighted"] or 0 for o in ojects if o["weighted"]) / max(sum(1 for o in ojects if o["weighted"]), 1)
|
avg_score = sum(o["weighted"] or 0 for o in ojects if o["weighted"]) / max(sum(1 for o in ojects if o["weighted"]), 1)
|
||||||
total_flats = sum(o["flat_count"] or 0 for o in ojects)
|
total_flats = sum(o["flat_count"] or 0 for o in ojects)
|
||||||
matched = [o for o in ojects if o["project"] and proj_data.get(o["project"])]
|
matched = [o for o in ojects if o["project"] and proj_data.get(o["project"])]
|
||||||
total_lots = sum(proj_data[o["project"]]["total"] for o in matched)
|
# Dedup by project: multiple sites can match the same Objective project,
|
||||||
sold_lots = sum(proj_data[o["project"]]["sold_n"] for o in matched)
|
# so sum lot stats over unique projects to avoid double-counting.
|
||||||
avg_price = (sum(proj_data[o["project"]]["avg_price"] or 0 for o in matched) / len(matched)) if matched else None
|
matched_projects = sorted({o["project"] for o in matched})
|
||||||
total_vel = sum(proj_data[o["project"]]["vel_6mo"] or 0 for o in matched)
|
total_lots = sum(proj_data[p]["total"] for p in matched_projects)
|
||||||
|
sold_lots = sum(proj_data[p]["sold_n"] for p in matched_projects)
|
||||||
|
avg_price = (sum(proj_data[p]["avg_price"] or 0 for p in matched_projects) / len(matched_projects)) if matched_projects else None
|
||||||
|
total_vel = sum(proj_data[p]["vel_6mo"] or 0 for p in matched_projects)
|
||||||
|
|
||||||
portfolio = []
|
portfolio = []
|
||||||
for o in ojects:
|
for o in ojects:
|
||||||
|
|
@ -994,7 +997,13 @@ def price_distribution(district: str):
|
||||||
vals.sort()
|
vals.sort()
|
||||||
if not vals: continue
|
if not vals: continue
|
||||||
n = len(vals)
|
n = len(vals)
|
||||||
def q(p): return vals[max(0, min(n-1, int(p*n)))]
|
def q(p):
|
||||||
|
# Linear-interpolated quantile at position p*(n-1) (avoids upward bias on small n).
|
||||||
|
pos = p * (n - 1)
|
||||||
|
lo = int(pos)
|
||||||
|
hi = min(lo + 1, n - 1)
|
||||||
|
frac = pos - lo
|
||||||
|
return vals[lo] + (vals[hi] - vals[lo]) * frac
|
||||||
out[k] = {"n": n, "min": vals[0], "p25": q(0.25), "p50": q(0.5),
|
out[k] = {"n": n, "min": vals[0], "p25": q(0.25), "p50": q(0.5),
|
||||||
"p75": q(0.75), "max": vals[-1]}
|
"p75": q(0.75), "max": vals[-1]}
|
||||||
return {"district": district, "by_class": out}
|
return {"district": district, "by_class": out}
|
||||||
|
|
@ -1013,20 +1022,27 @@ def district_time_machine(district: str):
|
||||||
if m == 0: m = 12; y -= 1
|
if m == 0: m = 12; y -= 1
|
||||||
months.reverse()
|
months.reverse()
|
||||||
series = []
|
series = []
|
||||||
|
import statistics
|
||||||
with _conn() as c:
|
with _conn() as c:
|
||||||
for mo in months:
|
for mo in months:
|
||||||
row = c.execute("""
|
row = c.execute("""
|
||||||
SELECT COUNT(*) AS deals,
|
SELECT COUNT(*) AS deals,
|
||||||
AVG(CASE WHEN price_per_m2>0 THEN price_per_m2/1000.0 END) AS price,
|
|
||||||
SUM(CASE WHEN area_pd>0 THEN area_pd END) AS volume_m2,
|
SUM(CASE WHEN area_pd>0 THEN area_pd END) AS volume_m2,
|
||||||
COUNT(DISTINCT project||'·'||corpus) AS corpuses
|
COUNT(DISTINCT project||'·'||corpus) AS corpuses
|
||||||
FROM objective_lots
|
FROM objective_lots
|
||||||
WHERE district=? AND substr(register_date,1,7)=?
|
WHERE district=? AND substr(register_date,1,7)=?
|
||||||
""", (district, mo)).fetchone()
|
""", (district, mo)).fetchone()
|
||||||
|
# True median price (SQLite has no MEDIAN aggregate) — compute in Python.
|
||||||
|
prices = [r["p"] for r in c.execute("""
|
||||||
|
SELECT price_per_m2/1000.0 AS p
|
||||||
|
FROM objective_lots
|
||||||
|
WHERE district=? AND substr(register_date,1,7)=? AND price_per_m2>0
|
||||||
|
""", (district, mo)).fetchall()]
|
||||||
|
median_price = statistics.median(prices) if prices else None
|
||||||
series.append({
|
series.append({
|
||||||
"month": mo,
|
"month": mo,
|
||||||
"deals": row["deals"] or 0,
|
"deals": row["deals"] or 0,
|
||||||
"median_price_kr": round(row["price"], 1) if row["price"] else None,
|
"median_price_kr": round(median_price, 1) if median_price is not None else None,
|
||||||
"volume_m2": round(row["volume_m2"], 1) if row["volume_m2"] else 0,
|
"volume_m2": round(row["volume_m2"], 1) if row["volume_m2"] else 0,
|
||||||
"active_corpuses": row["corpuses"] or 0,
|
"active_corpuses": row["corpuses"] or 0,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,8 @@ async def test_cian_auth(
|
||||||
state = await cian_session_svc.verify_session(cookies)
|
state = await cian_session_svc.verify_session(cookies)
|
||||||
if state is None:
|
if state is None:
|
||||||
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
|
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
|
||||||
|
if state.get("_ban"):
|
||||||
|
return {"authenticated": False, "userId": None, "reason": "banned_403"}
|
||||||
|
|
||||||
user_id = state.get("user", {}).get("userId")
|
user_id = state.get("user", {}).get("userId")
|
||||||
return {"authenticated": True, "userId": user_id, "reason": None}
|
return {"authenticated": True, "userId": user_id, "reason": None}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,18 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _map_row(row: object) -> dict[str, object]:
|
||||||
|
"""Маппит строку matview в поля SearchResultItem.
|
||||||
|
|
||||||
|
SQL отдаёт колонку `cadastral_number`, а в схеме поле зовётся
|
||||||
|
`kadastr_num` — без явного маппинга значение молча теряется (#1514).
|
||||||
|
"""
|
||||||
|
data = dict(row)
|
||||||
|
if "cadastral_number" in data:
|
||||||
|
data["kadastr_num"] = data.pop("cadastral_number")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=SearchResponse)
|
@router.post("/search", response_model=SearchResponse)
|
||||||
async def search(
|
async def search(
|
||||||
params: SearchParams,
|
params: SearchParams,
|
||||||
|
|
@ -37,7 +49,9 @@ async def search(
|
||||||
"search cache HIT key=%s page=%d size=%d",
|
"search cache HIT key=%s page=%d size=%d",
|
||||||
cache_key[:24], params.page, params.page_size,
|
cache_key[:24], params.page, params.page_size,
|
||||||
)
|
)
|
||||||
return SearchResponse.model_validate(cached)
|
resp = SearchResponse.model_validate(cached)
|
||||||
|
resp.cache_hit = True
|
||||||
|
return resp
|
||||||
|
|
||||||
sql, args = build_search_query(params)
|
sql, args = build_search_query(params)
|
||||||
rows = db.execute(text(sql), args).mappings().all()
|
rows = db.execute(text(sql), args).mappings().all()
|
||||||
|
|
@ -45,7 +59,7 @@ async def search(
|
||||||
count_sql, count_args = build_count_query(params)
|
count_sql, count_args = build_count_query(params)
|
||||||
total = int(db.execute(text(count_sql), count_args).scalar() or 0)
|
total = int(db.execute(text(count_sql), count_args).scalar() or 0)
|
||||||
|
|
||||||
items = [SearchResultItem.model_validate(dict(r)) for r in rows]
|
items = [SearchResultItem.model_validate(_map_row(r)) for r in rows]
|
||||||
elapsed_ms = (time.monotonic() - started) * 1000.0
|
elapsed_ms = (time.monotonic() - started) * 1000.0
|
||||||
response = SearchResponse(
|
response = SearchResponse(
|
||||||
items=items,
|
items=items,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
@ -396,7 +397,10 @@ def estimate_pdf(
|
||||||
confidence=row.confidence,
|
confidence=row.confidence,
|
||||||
confidence_explanation=row.confidence_explanation,
|
confidence_explanation=row.confidence_explanation,
|
||||||
n_analogs=row.n_analogs,
|
n_analogs=row.n_analogs,
|
||||||
period_months=24,
|
# #1351: окно сделок — 12 мес (estimator.DEALS_PERIOD_MONTHS), как в POST
|
||||||
|
# /estimate и GET /estimate/{id}. Раньше PDF-ветка хардкодила 24 →
|
||||||
|
# экспортёр рисовал ложный ~2-летний диапазон в клиентском документе.
|
||||||
|
period_months=12,
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
actual_deals=actual_deals,
|
actual_deals=actual_deals,
|
||||||
expires_at=row.expires_at,
|
expires_at=row.expires_at,
|
||||||
|
|
@ -1445,7 +1449,15 @@ def get_street_deals(
|
||||||
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
|
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
|
||||||
|
|
||||||
now = datetime.now(tz=UTC)
|
now = datetime.now(tz=UTC)
|
||||||
period_from: date = (now - timedelta(days=period_months * 30)).date()
|
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
|
||||||
|
# использует календарный interval PostgreSQL (NOW() - N months), а не
|
||||||
|
# фиксированные 30-дневные месяцы. Считаем cutoff календарной арифметикой.
|
||||||
|
_from_month0 = (now.year * 12 + (now.month - 1)) - period_months
|
||||||
|
_from_year, _from_month_idx = divmod(_from_month0, 12)
|
||||||
|
_from_month = _from_month_idx + 1
|
||||||
|
# Клампим день для коротких месяцев (как делает PostgreSQL interval).
|
||||||
|
_from_day = min(now.day, calendar.monthrange(_from_year, _from_month)[1])
|
||||||
|
period_from: date = date(_from_year, _from_month, _from_day)
|
||||||
period_to: date = now.date()
|
period_to: date = now.date()
|
||||||
|
|
||||||
def _empty() -> StreetDealsResponse:
|
def _empty() -> StreetDealsResponse:
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,36 @@ class SearchParams(BaseModel):
|
||||||
raise ValueError("lat and lon must be provided together")
|
raise ValueError("lat and lon must be provided together")
|
||||||
if self.sort == "dist_asc" and self.lat is None:
|
if self.sort == "dist_asc" and self.lat is None:
|
||||||
raise ValueError("sort=dist_asc requires lat+lon")
|
raise ValueError("sort=dist_asc requires lat+lon")
|
||||||
if self.price_rub_min and self.price_rub_max and self.price_rub_min > self.price_rub_max:
|
if (
|
||||||
|
self.price_rub_min is not None
|
||||||
|
and self.price_rub_max is not None
|
||||||
|
and self.price_rub_min > self.price_rub_max
|
||||||
|
):
|
||||||
raise ValueError("price_rub_min > price_rub_max")
|
raise ValueError("price_rub_min > price_rub_max")
|
||||||
if self.area_m2_min and self.area_m2_max and self.area_m2_min > self.area_m2_max:
|
if (
|
||||||
|
self.area_m2_min is not None
|
||||||
|
and self.area_m2_max is not None
|
||||||
|
and self.area_m2_min > self.area_m2_max
|
||||||
|
):
|
||||||
raise ValueError("area_m2_min > area_m2_max")
|
raise ValueError("area_m2_min > area_m2_max")
|
||||||
|
if (
|
||||||
|
self.floor_min is not None
|
||||||
|
and self.floor_max is not None
|
||||||
|
and self.floor_min > self.floor_max
|
||||||
|
):
|
||||||
|
raise ValueError("floor_min > floor_max")
|
||||||
|
if (
|
||||||
|
self.year_built_min is not None
|
||||||
|
and self.year_built_max is not None
|
||||||
|
and self.year_built_min > self.year_built_max
|
||||||
|
):
|
||||||
|
raise ValueError("year_built_min > year_built_max")
|
||||||
|
if (
|
||||||
|
self.floors_total_min is not None
|
||||||
|
and self.floors_total_max is not None
|
||||||
|
and self.floors_total_min > self.floors_total_max
|
||||||
|
):
|
||||||
|
raise ValueError("floors_total_min > floors_total_max")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,26 @@ async def backfill_cian_price_history(
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
# Count rows actually inserted: save_detail_enrichment skips
|
||||||
|
# changes without change_time/price_rub and uses ON CONFLICT
|
||||||
|
# DO NOTHING, so len(price_changes) overcounts on invalid
|
||||||
|
# elements or idempotent re-runs. Diff the row count instead.
|
||||||
|
before = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM offer_price_history "
|
||||||
|
"WHERE listing_id = CAST(:lid AS bigint)"
|
||||||
|
),
|
||||||
|
{"lid": lid},
|
||||||
|
).scalar_one()
|
||||||
save_detail_enrichment(db, lid, enrichment)
|
save_detail_enrichment(db, lid, enrichment)
|
||||||
result.saved += len(enrichment.price_changes)
|
after = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM offer_price_history "
|
||||||
|
"WHERE listing_id = CAST(:lid AS bigint)"
|
||||||
|
),
|
||||||
|
{"lid": lid},
|
||||||
|
).scalar_one()
|
||||||
|
result.saved += max(0, int(after) - int(before))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("cian_price_history: save failed listing_id=%s: %s", lid, exc)
|
logger.warning("cian_price_history: save failed listing_id=%s: %s", lid, exc)
|
||||||
result.errors += 1
|
result.errors += 1
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue