Merge main (post-PR191 competitors + PR192 forgejo workflow) into feat/115-connection-points-backend
# Conflicts: # backend/app/api/v1/parcels.py
This commit is contained in:
commit
3ee2a99958
5 changed files with 777 additions and 80 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
name: Deploy
|
name: Deploy
|
||||||
|
|
||||||
# Деплоится только при изменениях основного стека.
|
# Forgejo Actions equivalent of .github/workflows/deploy.yml
|
||||||
# Obsidian-стек (CouchDB) — отдельный workflow `deploy-obsidian.yml`.
|
# Migration 2026-05-16: GitHub → Forgejo (git.gendsgn.ru)
|
||||||
|
# Builds images on Forgejo runner, pushes to ghcr.io (GitHub Container Registry),
|
||||||
|
# SSH-deploys to Beget VPS (46.173.16.127).
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
@ -10,10 +12,8 @@ on:
|
||||||
- "frontend/**"
|
- "frontend/**"
|
||||||
- "docker-compose.prod.yml"
|
- "docker-compose.prod.yml"
|
||||||
- "Caddyfile"
|
- "Caddyfile"
|
||||||
- ".github/workflows/deploy.yml"
|
- ".forgejo/workflows/deploy.yml"
|
||||||
- "data/sql/*.sql"
|
- "data/sql/*.sql"
|
||||||
# NB: shared compose-fragments (network) — тоже триггерят main
|
|
||||||
# потому что Caddy шарит сеть с obsidian-stack
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|
@ -28,9 +28,6 @@ env:
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
outputs:
|
outputs:
|
||||||
backend: ${{ steps.filter.outputs.backend }}
|
backend: ${{ steps.filter.outputs.backend }}
|
||||||
frontend: ${{ steps.filter.outputs.frontend }}
|
frontend: ${{ steps.filter.outputs.frontend }}
|
||||||
|
|
@ -43,19 +40,17 @@ jobs:
|
||||||
filters: |
|
filters: |
|
||||||
backend:
|
backend:
|
||||||
- 'backend/**'
|
- 'backend/**'
|
||||||
|
- 'data/sql/**'
|
||||||
frontend:
|
frontend:
|
||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
infra:
|
infra:
|
||||||
- 'docker-compose.prod.yml'
|
- 'docker-compose.prod.yml'
|
||||||
- 'Caddyfile'
|
- 'Caddyfile'
|
||||||
- '.github/workflows/deploy.yml'
|
- '.forgejo/workflows/deploy.yml'
|
||||||
|
|
||||||
build-backend:
|
build-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
needs: changes
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
if: |
|
if: |
|
||||||
needs.changes.outputs.backend == 'true' ||
|
needs.changes.outputs.backend == 'true' ||
|
||||||
needs.changes.outputs.infra == 'true' ||
|
needs.changes.outputs.infra == 'true' ||
|
||||||
|
|
@ -63,12 +58,11 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions)
|
||||||
uses: docker/login-action@v3
|
env:
|
||||||
with:
|
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||||
registry: ghcr.io
|
run: |
|
||||||
username: ${{ github.actor }}
|
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -79,8 +73,8 @@ jobs:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
target: runner
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
cache-from: type=gha,scope=backend-lean
|
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||||
cache-to: type=gha,mode=max,scope=backend-lean
|
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_BACKEND }}:latest
|
${{ env.IMAGE_BACKEND }}:latest
|
||||||
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
||||||
|
|
@ -88,9 +82,6 @@ jobs:
|
||||||
build-worker:
|
build-worker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
needs: changes
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
if: |
|
if: |
|
||||||
needs.changes.outputs.backend == 'true' ||
|
needs.changes.outputs.backend == 'true' ||
|
||||||
needs.changes.outputs.infra == 'true' ||
|
needs.changes.outputs.infra == 'true' ||
|
||||||
|
|
@ -98,12 +89,11 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions)
|
||||||
uses: docker/login-action@v3
|
env:
|
||||||
with:
|
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||||
registry: ghcr.io
|
run: |
|
||||||
username: ${{ github.actor }}
|
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -114,8 +104,8 @@ jobs:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
target: runner-with-chromium
|
target: runner-with-chromium
|
||||||
push: true
|
push: true
|
||||||
cache-from: type=gha,scope=worker-chromium
|
cache-from: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache
|
||||||
cache-to: type=gha,mode=max,scope=worker-chromium
|
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_WORKER }}:latest
|
${{ env.IMAGE_WORKER }}:latest
|
||||||
${{ env.IMAGE_WORKER }}:${{ github.sha }}
|
${{ env.IMAGE_WORKER }}:${{ github.sha }}
|
||||||
|
|
@ -123,9 +113,6 @@ jobs:
|
||||||
build-frontend:
|
build-frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
needs: changes
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
if: |
|
if: |
|
||||||
needs.changes.outputs.frontend == 'true' ||
|
needs.changes.outputs.frontend == 'true' ||
|
||||||
needs.changes.outputs.infra == 'true' ||
|
needs.changes.outputs.infra == 'true' ||
|
||||||
|
|
@ -133,12 +120,11 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions)
|
||||||
uses: docker/login-action@v3
|
env:
|
||||||
with:
|
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||||
registry: ghcr.io
|
run: |
|
||||||
username: ${{ github.actor }}
|
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -148,8 +134,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
push: true
|
push: true
|
||||||
cache-from: type=gha,scope=frontend
|
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||||
cache-to: type=gha,mode=max,scope=frontend
|
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_FRONTEND }}:latest
|
${{ env.IMAGE_FRONTEND }}:latest
|
||||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||||
|
|
@ -157,10 +143,6 @@ jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [changes, build-backend, build-worker, build-frontend]
|
needs: [changes, build-backend, build-worker, build-frontend]
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# Запускается если ни один build не завершился failure.
|
|
||||||
# `always()` позволяет выполнить job даже когда зависимые jobs были skipped.
|
|
||||||
if: |
|
if: |
|
||||||
always() &&
|
always() &&
|
||||||
!cancelled() &&
|
!cancelled() &&
|
||||||
|
|
@ -173,26 +155,26 @@ jobs:
|
||||||
env:
|
env:
|
||||||
IMAGE_TAG: latest
|
IMAGE_TAG: latest
|
||||||
SENTRY_RELEASE_VAL: ${{ github.sha }}
|
SENTRY_RELEASE_VAL: ${{ github.sha }}
|
||||||
|
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.DEPLOY_USER }}
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
port: ${{ secrets.DEPLOY_PORT || 22 }}
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
envs: IMAGE_TAG,SENTRY_RELEASE_VAL
|
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd /opt/gendesign
|
cd /opt/gendesign
|
||||||
|
|
||||||
# Sync compose / Caddyfile / init scripts from the repo.
|
# Sync compose / Caddyfile / init scripts from the repo.
|
||||||
# --ff-only refuses if there are local commits on the VM (we don't expect any).
|
# Origin теперь Forgejo (после migration 2026-05-16) — HTTPS basic auth
|
||||||
|
# через PAT в URL не нужен т.к. repo читается через deploy key (SSH)
|
||||||
|
# ИЛИ public read-only mode. Forgejo gendesign — private, нужен auth:
|
||||||
|
# `git remote get-url origin` должен указывать на Forgejo с auth.
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
|
|
||||||
# Sentry release tracking — записываем git-sha в .env.runtime.
|
# Sentry release tracking
|
||||||
# ВАЖНО: НЕ перезаписываем файл целиком (там могут быть
|
|
||||||
# COUCHDB_PASSWORD и др. user-managed secrets). Заменяем только
|
|
||||||
# строку SENTRY_RELEASE или добавляем если её нет.
|
|
||||||
# IMAGE_TAG=latest для docker pull; SENTRY_RELEASE_VAL = git sha для трекинга.
|
|
||||||
mkdir -p backend
|
mkdir -p backend
|
||||||
touch backend/.env.runtime
|
touch backend/.env.runtime
|
||||||
if grep -q '^SENTRY_RELEASE=' backend/.env.runtime; then
|
if grep -q '^SENTRY_RELEASE=' backend/.env.runtime; then
|
||||||
|
|
@ -202,27 +184,19 @@ jobs:
|
||||||
fi
|
fi
|
||||||
chmod 600 backend/.env.runtime
|
chmod 600 backend/.env.runtime
|
||||||
|
|
||||||
# Создать external network если её нет (нужна Caddy для маршрута
|
# External network для Caddy + obsidian-stack share
|
||||||
# obsidian.gendsgn.ru → couchdb из отдельного obsidian-stack).
|
|
||||||
docker network inspect gendesign_shared >/dev/null 2>&1 \
|
docker network inspect gendesign_shared >/dev/null 2>&1 \
|
||||||
|| docker network create gendesign_shared
|
|| docker network create gendesign_shared
|
||||||
|
|
||||||
|
# Re-login to GHCR (PAT может быть rotated после initial setup)
|
||||||
|
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||||
|
|
||||||
export IMAGE_TAG="$IMAGE_TAG"
|
export IMAGE_TAG="$IMAGE_TAG"
|
||||||
# Project name явно — `gendesign` (по имени папки auto), но
|
|
||||||
# фиксируем для consistency с obsidian-stack (-p gendesign-obsidian).
|
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml pull
|
docker compose -p gendesign -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
# ── Apply pending SQL migrations ──────────────────────────────────
|
# Apply pending SQL migrations
|
||||||
# Tracking table: _schema_migrations (filename TEXT PRIMARY KEY).
|
|
||||||
# Each NN_*.sql file is applied exactly once, in sort order.
|
|
||||||
# ON_ERROR_STOP=on ensures a failing migration aborts the deploy.
|
|
||||||
# POSTGRES_USER/PASSWORD/DB живут в /opt/gendesign/.env (root —
|
|
||||||
# их читает compose через env_file для postgres service).
|
|
||||||
# backend/.env содержит ТОЛЬКО backend-app vars (SCRAPE_ADMIN_TOKEN,
|
|
||||||
# OPENROUTESERVICE_API_KEY, etc), без POSTGRES_*.
|
|
||||||
set -a; source .env; set +a
|
set -a; source .env; set +a
|
||||||
|
|
||||||
# Ensure tracking table exists (idempotent).
|
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=on -c "
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=on -c "
|
||||||
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
||||||
|
|
@ -231,7 +205,6 @@ jobs:
|
||||||
);
|
);
|
||||||
"
|
"
|
||||||
|
|
||||||
# Apply each pending .sql file in NN order.
|
|
||||||
for sql_file in $(ls -1 data/sql/*.sql 2>/dev/null | sort); do
|
for sql_file in $(ls -1 data/sql/*.sql 2>/dev/null | sort); do
|
||||||
fname=$(basename "$sql_file")
|
fname=$(basename "$sql_file")
|
||||||
applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
|
@ -251,18 +224,13 @@ jobs:
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "All migrations applied."
|
echo "All migrations applied."
|
||||||
# ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml up -d
|
docker compose -p gendesign -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
# Caddyfile is bind-mounted; up -d won't re-read it. Reload explicitly.
|
|
||||||
# `|| true` so a temporary Caddy hiccup doesn't fail the whole deploy.
|
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
||||||
caddy reload --config /etc/caddy/Caddyfile || true
|
caddy reload --config /etc/caddy/Caddyfile || true
|
||||||
|
|
||||||
# Clean up disk — aggressive чтобы избежать накопления.
|
# Cleanup old images
|
||||||
# 1. Для каждого gendesign-* repo оставляем 2 самых свежих SHA-тега
|
|
||||||
# + :latest. Docker images сортирует по Created DESC.
|
|
||||||
for repo in ghcr.io/lekss361/gendesign-backend \
|
for repo in ghcr.io/lekss361/gendesign-backend \
|
||||||
ghcr.io/lekss361/gendesign-worker \
|
ghcr.io/lekss361/gendesign-worker \
|
||||||
ghcr.io/lekss361/gendesign-frontend; do
|
ghcr.io/lekss361/gendesign-frontend; do
|
||||||
|
|
@ -271,14 +239,10 @@ jobs:
|
||||||
| tail -n +3 \
|
| tail -n +3 \
|
||||||
| xargs -r docker rmi 2>/dev/null || true
|
| xargs -r docker rmi 2>/dev/null || true
|
||||||
done
|
done
|
||||||
# 2. ВСЕ unused images (даже свежие). Running контейнеры безопасны
|
|
||||||
# — их images не unused. Раньше был filter until=72h, но при
|
|
||||||
# частых деплоях оставались "fresh dangling" layers, накапливая
|
|
||||||
# до 50GB за неделю.
|
|
||||||
docker image prune -af || true
|
docker image prune -af || true
|
||||||
docker builder prune -af || true
|
docker builder prune -af || true
|
||||||
|
|
||||||
# Wait for backend to be healthy (up to 30 s).
|
# Health check
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
curl -fsS http://localhost:8000/health && break
|
curl -fsS http://localhost:8000/health && break
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
@ -15,6 +15,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
|
||||||
from app.schemas.parcel import (
|
from app.schemas.parcel import (
|
||||||
|
CompetitorsRequest,
|
||||||
|
CompetitorsResponse,
|
||||||
ConnectionPointsResponse,
|
ConnectionPointsResponse,
|
||||||
ParcelDetail,
|
ParcelDetail,
|
||||||
ParcelSearchRequest,
|
ParcelSearchRequest,
|
||||||
|
|
@ -27,6 +29,7 @@ from app.services.site_finder.cadastre_fetch import (
|
||||||
from app.services.site_finder.cadastre_fetch import (
|
from app.services.site_finder.cadastre_fetch import (
|
||||||
fetch_status as _fetch_status,
|
fetch_status as _fetch_status,
|
||||||
)
|
)
|
||||||
|
from app.services.site_finder.competitors import get_competitors
|
||||||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||||||
from app.services.site_finder.quarter_dump_lookup import (
|
from app.services.site_finder.quarter_dump_lookup import (
|
||||||
get_connection_points,
|
get_connection_points,
|
||||||
|
|
@ -2080,3 +2083,26 @@ def get_isochrones(
|
||||||
"source": "openrouteservice.org",
|
"source": "openrouteservice.org",
|
||||||
"note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.",
|
"note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{cad_num}/competitors", response_model=CompetitorsResponse)
|
||||||
|
async def get_parcel_competitors(
|
||||||
|
cad_num: str,
|
||||||
|
body: CompetitorsRequest,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> CompetitorsResponse:
|
||||||
|
"""Активные конкуренты ЖК в радиусе от участка (Issue #112).
|
||||||
|
|
||||||
|
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
||||||
|
участка с рассчитанным velocity_per_month за указанный time_window.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return get_competitors(db=db, cad_num=cad_num, request=body)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("competitors endpoint failed for %s: %s", cad_num, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Ошибка расчёта конкурентов",
|
||||||
|
) from exc
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
@ -89,3 +89,46 @@ class ParcelSearchResponse(BaseModel):
|
||||||
class ParcelDetail(ParcelSummary):
|
class ParcelDetail(ParcelSummary):
|
||||||
geometry_geojson: dict[str, Any]
|
geometry_geojson: dict[str, Any]
|
||||||
enrichment: dict[str, Any]
|
enrichment: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Competitors endpoint schemas ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
TimeWindow = Literal["last_month", "last_quarter", "last_year"]
|
||||||
|
ObjClassFilter = Literal["economy", "comfort", "business"]
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitorsRequest(BaseModel):
|
||||||
|
radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
|
||||||
|
time_window: TimeWindow = "last_quarter"
|
||||||
|
obj_class_filter: ObjClassFilter | None = None
|
||||||
|
exclude_obj_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Competitor(BaseModel):
|
||||||
|
obj_id: int
|
||||||
|
comm_name: str | None
|
||||||
|
dev_name: str | None
|
||||||
|
obj_class: str | None
|
||||||
|
distance_m: float
|
||||||
|
lat: float
|
||||||
|
lng: float
|
||||||
|
stage: str | None
|
||||||
|
flats_total: int | None
|
||||||
|
flats_sold: int | None
|
||||||
|
sold_pct: float | None
|
||||||
|
velocity_per_month: float
|
||||||
|
avg_price_per_m2: float | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitorsSummary(BaseModel):
|
||||||
|
total_competitors: int
|
||||||
|
active_count: int
|
||||||
|
weighted_avg_velocity: float
|
||||||
|
radius_km: float
|
||||||
|
time_window: str
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitorsResponse(BaseModel):
|
||||||
|
competitors: list[Competitor]
|
||||||
|
summary: CompetitorsSummary
|
||||||
|
|
|
||||||
303
backend/app/services/site_finder/competitors.py
Normal file
303
backend/app/services/site_finder/competitors.py
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
"""Анализ активных конкурентов ЖК в радиусе от участка.
|
||||||
|
|
||||||
|
Issue #112 — Demand: активные конкуренты, продажи ЖК в радиусе 1км за квартал.
|
||||||
|
|
||||||
|
Источники:
|
||||||
|
domrf_kn_objects — ЖК с lat/lon, flat_count, obj_class, site_status
|
||||||
|
objective_complex_mapping — domrf_obj_id → objective_complex_name
|
||||||
|
objective_corpus_room_month — monthly deals_total_count per project_name
|
||||||
|
cad_parcels_geom — centroid участка (fallback: cad_quarters_geom)
|
||||||
|
domrf_kn_flats — avg price_per_m2 по проданным квартирам
|
||||||
|
|
||||||
|
Внимание: velocity coverage ~2.5% — большинство ЖК не имеют маппинга в
|
||||||
|
objective_complex_mapping. LEFT JOIN гарантирует velocity=0 (не ошибку) для
|
||||||
|
немаппированных объектов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.schemas.parcel import (
|
||||||
|
Competitor,
|
||||||
|
CompetitorsRequest,
|
||||||
|
CompetitorsResponse,
|
||||||
|
CompetitorsSummary,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Маппинг time_window → число месяцев (float для деления velocity)
|
||||||
|
_TIME_WINDOW_MONTHS: dict[str, float] = {
|
||||||
|
"last_month": 1.0,
|
||||||
|
"last_quarter": 3.0,
|
||||||
|
"last_year": 12.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# site_status значения, считающиеся «активными»
|
||||||
|
_ACTIVE_STATUSES = frozenset({"sales", "construction"})
|
||||||
|
|
||||||
|
# SQL для получения центроида участка
|
||||||
|
_PARCEL_CENTROID_SQL = text("""
|
||||||
|
SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat
|
||||||
|
FROM (
|
||||||
|
SELECT ST_Centroid(geom) AS pt
|
||||||
|
FROM cad_parcels_geom
|
||||||
|
WHERE cad_num = :cad_num AND geom IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ST_Centroid(geom) AS pt
|
||||||
|
FROM cad_quarters_geom
|
||||||
|
WHERE cad_number = :quarter AND geom IS NOT NULL
|
||||||
|
) sub
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Основной запрос конкурентов в радиусе.
|
||||||
|
# Velocity через objective_corpus_room_month (актуальные данные, обновляется еженедельно).
|
||||||
|
# domrf_kn_sale_graph устарел (данные до 2026-01) — не используется.
|
||||||
|
# Coverage velocity ~2.5%: большинство obj_id нет в objective_complex_mapping →
|
||||||
|
# LEFT JOIN → velocity=0 (не ошибка).
|
||||||
|
_COMPETITORS_SQL = text("""
|
||||||
|
WITH latest_obj AS (
|
||||||
|
SELECT DISTINCT ON (obj_id)
|
||||||
|
obj_id,
|
||||||
|
comm_name,
|
||||||
|
dev_name,
|
||||||
|
obj_class,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
flat_count,
|
||||||
|
site_status,
|
||||||
|
snapshot_date
|
||||||
|
FROM domrf_kn_objects
|
||||||
|
WHERE latitude IS NOT NULL
|
||||||
|
AND longitude IS NOT NULL
|
||||||
|
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||||
|
),
|
||||||
|
mapped AS (
|
||||||
|
SELECT cm.domrf_obj_id AS obj_id,
|
||||||
|
cm.objective_complex_name
|
||||||
|
FROM objective_complex_mapping cm
|
||||||
|
),
|
||||||
|
velocity AS (
|
||||||
|
SELECT
|
||||||
|
m.obj_id,
|
||||||
|
SUM(COALESCE(crm.deals_total_count, 0))
|
||||||
|
/ CAST(:time_window_months AS float) AS velocity_per_month
|
||||||
|
FROM objective_corpus_room_month crm
|
||||||
|
JOIN mapped m ON m.objective_complex_name = crm.project_name
|
||||||
|
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))
|
||||||
|
GROUP BY m.obj_id
|
||||||
|
),
|
||||||
|
distances AS (
|
||||||
|
SELECT
|
||||||
|
o.obj_id,
|
||||||
|
o.comm_name,
|
||||||
|
o.dev_name,
|
||||||
|
o.obj_class,
|
||||||
|
o.latitude,
|
||||||
|
o.longitude,
|
||||||
|
o.flat_count,
|
||||||
|
o.site_status,
|
||||||
|
ST_Distance(
|
||||||
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)),
|
||||||
|
4326
|
||||||
|
)::geography
|
||||||
|
) AS distance_m
|
||||||
|
FROM latest_obj o
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
d.obj_id,
|
||||||
|
d.comm_name,
|
||||||
|
d.dev_name,
|
||||||
|
d.obj_class,
|
||||||
|
d.latitude,
|
||||||
|
d.longitude,
|
||||||
|
d.flat_count,
|
||||||
|
d.site_status,
|
||||||
|
d.distance_m,
|
||||||
|
COALESCE(v.velocity_per_month, 0.0) AS velocity_per_month
|
||||||
|
FROM distances d
|
||||||
|
LEFT JOIN velocity v ON v.obj_id = d.obj_id
|
||||||
|
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
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Средняя цена м² по проданным квартирам для набора obj_id
|
||||||
|
_AVG_PRICE_SQL = text("""
|
||||||
|
SELECT
|
||||||
|
f.obj_id,
|
||||||
|
AVG(f.price_per_m2) AS avg_price_per_m2
|
||||||
|
FROM domrf_kn_flats f
|
||||||
|
WHERE f.obj_id = ANY(:obj_ids)
|
||||||
|
AND f.price_per_m2 IS NOT NULL
|
||||||
|
AND f.status = 'sold'
|
||||||
|
GROUP BY f.obj_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _quarter_from_cad(cad_num: str) -> str:
|
||||||
|
"""Извлечь кадастровый квартал из номера участка/здания.
|
||||||
|
|
||||||
|
66:41:0303161:123 → 66:41:0303161
|
||||||
|
Если формат нестандартный — возвращаем cad_num как есть (fallback).
|
||||||
|
"""
|
||||||
|
parts = cad_num.split(":")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
return ":".join(parts[:3])
|
||||||
|
return cad_num
|
||||||
|
|
||||||
|
|
||||||
|
def get_competitors(
|
||||||
|
db: Session,
|
||||||
|
cad_num: str,
|
||||||
|
request: CompetitorsRequest,
|
||||||
|
) -> CompetitorsResponse:
|
||||||
|
"""Получить список конкурентов ЖК в радиусе от участка.
|
||||||
|
|
||||||
|
Шаги:
|
||||||
|
1. Найти центроид участка (cad_parcels_geom → cad_quarters_geom fallback).
|
||||||
|
2. Выбрать ЖК из domrf_kn_objects в радиусе с velocity из objective_corpus_room_month.
|
||||||
|
3. Применить exclude_obj_ids фильтр в Python (избегаем array cast).
|
||||||
|
4. Подтянуть avg_price_per_m2 из domrf_kn_flats.
|
||||||
|
5. Собрать CompetitorsResponse.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: если центроид участка не найден (caller должен вернуть 404).
|
||||||
|
"""
|
||||||
|
quarter = _quarter_from_cad(cad_num)
|
||||||
|
|
||||||
|
# ── 1. Центроид участка ──────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
coord_row = (
|
||||||
|
db.execute(
|
||||||
|
_PARCEL_CENTROID_SQL,
|
||||||
|
{"cad_num": cad_num, "quarter": quarter},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("competitors: centroid query failed for cad_num=%s", cad_num)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not coord_row:
|
||||||
|
raise ValueError(f"Геометрия для {cad_num} не найдена")
|
||||||
|
|
||||||
|
center_lat = float(coord_row["lat"])
|
||||||
|
center_lon = float(coord_row["lon"])
|
||||||
|
|
||||||
|
# ── 2. Конкуренты в радиусе ──────────────────────────────────────────────
|
||||||
|
time_window_months = _TIME_WINDOW_MONTHS[request.time_window]
|
||||||
|
window_interval = f"{int(time_window_months)} months"
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
_COMPETITORS_SQL,
|
||||||
|
{
|
||||||
|
"center_lat": center_lat,
|
||||||
|
"center_lon": center_lon,
|
||||||
|
"radius_m": request.radius_km * 1000.0,
|
||||||
|
"time_window_months": time_window_months,
|
||||||
|
"window_interval": window_interval,
|
||||||
|
"obj_class_filter": request.obj_class_filter,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"competitors: main query failed for cad_num=%s radius_km=%.2f",
|
||||||
|
cad_num,
|
||||||
|
request.radius_km,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ── 3. Применить exclude_obj_ids ─────────────────────────────────────────
|
||||||
|
exclude_set = set(request.exclude_obj_ids)
|
||||||
|
if exclude_set:
|
||||||
|
rows = [r for r in rows if int(r["obj_id"]) not in exclude_set]
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return CompetitorsResponse(
|
||||||
|
competitors=[],
|
||||||
|
summary=CompetitorsSummary(
|
||||||
|
total_competitors=0,
|
||||||
|
active_count=0,
|
||||||
|
weighted_avg_velocity=0.0,
|
||||||
|
radius_km=request.radius_km,
|
||||||
|
time_window=request.time_window,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
obj_ids: list[int] = [int(r["obj_id"]) for r in rows]
|
||||||
|
|
||||||
|
# ── 4. Средняя цена м² (graceful — таблица может быть не заполнена) ──────
|
||||||
|
avg_price_map: dict[int, float] = {}
|
||||||
|
try:
|
||||||
|
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||||
|
avg_price_map = {
|
||||||
|
int(r["obj_id"]): float(r["avg_price_per_m2"])
|
||||||
|
for r in price_rows
|
||||||
|
if r["avg_price_per_m2"] is not None
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.warning("competitors: avg_price query failed, continuing without prices")
|
||||||
|
|
||||||
|
# ── 5. Сборка результата ─────────────────────────────────────────────────
|
||||||
|
# flats_sold / sold_pct: не доступны из domrf_kn_objects (только flat_count).
|
||||||
|
# Можно получить через COUNT(domrf_kn_flats WHERE status='sold') —
|
||||||
|
# отложено за MVP, поля остаются None.
|
||||||
|
competitors: list[Competitor] = []
|
||||||
|
for r in rows:
|
||||||
|
obj_id = int(r["obj_id"])
|
||||||
|
flats_total = int(r["flat_count"]) if r["flat_count"] is not None else None
|
||||||
|
|
||||||
|
site_status = r["site_status"]
|
||||||
|
is_active = site_status in _ACTIVE_STATUSES if site_status else False
|
||||||
|
|
||||||
|
competitors.append(
|
||||||
|
Competitor(
|
||||||
|
obj_id=obj_id,
|
||||||
|
comm_name=r["comm_name"],
|
||||||
|
dev_name=r["dev_name"],
|
||||||
|
obj_class=r["obj_class"],
|
||||||
|
distance_m=round(float(r["distance_m"]), 1),
|
||||||
|
lat=float(r["latitude"]),
|
||||||
|
lng=float(r["longitude"]),
|
||||||
|
stage=site_status,
|
||||||
|
flats_total=flats_total,
|
||||||
|
flats_sold=None,
|
||||||
|
sold_pct=None,
|
||||||
|
velocity_per_month=round(float(r["velocity_per_month"]), 2),
|
||||||
|
avg_price_per_m2=avg_price_map.get(obj_id),
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 6. Summary ───────────────────────────────────────────────────────────
|
||||||
|
active_count = sum(1 for c in competitors if c.is_active)
|
||||||
|
total_velocity = sum(c.velocity_per_month for c in competitors)
|
||||||
|
n = len(competitors)
|
||||||
|
weighted_avg_velocity = round(total_velocity / n, 2) if n > 0 else 0.0
|
||||||
|
|
||||||
|
summary = CompetitorsSummary(
|
||||||
|
total_competitors=n,
|
||||||
|
active_count=active_count,
|
||||||
|
weighted_avg_velocity=weighted_avg_velocity,
|
||||||
|
radius_km=request.radius_km,
|
||||||
|
time_window=request.time_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CompetitorsResponse(competitors=competitors, summary=summary)
|
||||||
361
backend/tests/api/v1/test_parcel_competitors.py
Normal file
361
backend/tests/api/v1/test_parcel_competitors.py
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
"""Тесты для POST /api/v1/parcels/{cad_num}/competitors (Issue #112).
|
||||||
|
|
||||||
|
Mock-based — не требуют живой БД.
|
||||||
|
Паттерн mock DB: аналогично test_admin_cadastre.py — dependency_overrides[get_db].
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _coord_row(lat: float = 56.838, lon: float = 60.605) -> MagicMock:
|
||||||
|
"""Строка центроида участка."""
|
||||||
|
r = MagicMock()
|
||||||
|
r.__getitem__ = lambda self, k: {"lat": lat, "lon": lon}[k]
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _obj_row(
|
||||||
|
obj_id: int = 1,
|
||||||
|
distance_m: float = 400.0,
|
||||||
|
site_status: str = "sales",
|
||||||
|
obj_class: str | None = "comfort",
|
||||||
|
velocity: float = 5.0,
|
||||||
|
flat_count: int | None = 200,
|
||||||
|
) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.__getitem__ = lambda self, k: {
|
||||||
|
"obj_id": obj_id,
|
||||||
|
"comm_name": f"ЖК-{obj_id}",
|
||||||
|
"dev_name": "TestDev",
|
||||||
|
"obj_class": obj_class,
|
||||||
|
"latitude": 56.838 + distance_m / 1_000_000,
|
||||||
|
"longitude": 60.605 + distance_m / 1_000_000,
|
||||||
|
"flat_count": flat_count,
|
||||||
|
"site_status": site_status,
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"velocity_per_month": velocity,
|
||||||
|
}[k]
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _price_row(obj_id: int, price: float) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "avg_price_per_m2": price}[k]
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# ── Построение mock DB ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db(
|
||||||
|
coord: MagicMock | None = None,
|
||||||
|
obj_rows: list[MagicMock] | None = None,
|
||||||
|
price_rows: list[MagicMock] | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Сконструировать mock Session.
|
||||||
|
|
||||||
|
Порядок вызовов execute:
|
||||||
|
1. centroid query → coord
|
||||||
|
2. competitors query → obj_rows
|
||||||
|
3. avg_price query → price_rows
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
results: list[MagicMock] = []
|
||||||
|
for rows, is_first in [
|
||||||
|
(coord, True),
|
||||||
|
(obj_rows or [], False),
|
||||||
|
(price_rows or [], False),
|
||||||
|
]:
|
||||||
|
result = MagicMock()
|
||||||
|
if is_first:
|
||||||
|
# centroid → .mappings().first()
|
||||||
|
result.mappings.return_value.first.return_value = rows
|
||||||
|
else:
|
||||||
|
result.mappings.return_value.all.return_value = rows
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
db.execute.side_effect = results
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _override_db(db: MagicMock):
|
||||||
|
def _get_db_override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
return _get_db_override
|
||||||
|
|
||||||
|
|
||||||
|
# ── Тесты ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_basic() -> None:
|
||||||
|
"""3 конкурента → корректная форма ответа, сортировка по distance."""
|
||||||
|
rows = [
|
||||||
|
_obj_row(obj_id=1, distance_m=200.0, velocity=4.0),
|
||||||
|
_obj_row(obj_id=2, distance_m=500.0, velocity=6.0),
|
||||||
|
_obj_row(obj_id=3, distance_m=900.0, velocity=2.0),
|
||||||
|
]
|
||||||
|
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:123/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "competitors" in body
|
||||||
|
assert "summary" in body
|
||||||
|
assert len(body["competitors"]) == 3
|
||||||
|
# первый должен быть ближайшим
|
||||||
|
assert body["competitors"][0]["obj_id"] == 1
|
||||||
|
assert body["competitors"][0]["distance_m"] == pytest.approx(200.0)
|
||||||
|
# все поля присутствуют
|
||||||
|
first = body["competitors"][0]
|
||||||
|
for key in (
|
||||||
|
"obj_id",
|
||||||
|
"comm_name",
|
||||||
|
"dev_name",
|
||||||
|
"obj_class",
|
||||||
|
"distance_m",
|
||||||
|
"lat",
|
||||||
|
"lng",
|
||||||
|
"stage",
|
||||||
|
"flats_total",
|
||||||
|
"flats_sold",
|
||||||
|
"sold_pct",
|
||||||
|
"velocity_per_month",
|
||||||
|
"avg_price_per_m2",
|
||||||
|
"is_active",
|
||||||
|
):
|
||||||
|
assert key in first, f"missing key: {key}"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_summary_calc() -> None:
|
||||||
|
"""summary: total_competitors, active_count, weighted_avg_velocity корректны."""
|
||||||
|
rows = [
|
||||||
|
_obj_row(obj_id=1, site_status="sales", velocity=10.0),
|
||||||
|
_obj_row(obj_id=2, site_status="construction", velocity=6.0),
|
||||||
|
_obj_row(obj_id=3, site_status="completed", velocity=2.0),
|
||||||
|
]
|
||||||
|
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
summary = resp.json()["summary"]
|
||||||
|
assert summary["total_competitors"] == 3
|
||||||
|
assert summary["active_count"] == 2 # sales + construction
|
||||||
|
# avg velocity = (10+6+2)/3 = 6.0
|
||||||
|
assert summary["weighted_avg_velocity"] == pytest.approx(6.0)
|
||||||
|
assert summary["radius_km"] == pytest.approx(1.0)
|
||||||
|
assert summary["time_window"] == "last_quarter"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_exclude_obj_ids() -> None:
|
||||||
|
"""exclude_obj_ids исключает указанные ЖК из результата."""
|
||||||
|
rows = [
|
||||||
|
_obj_row(obj_id=1, distance_m=100.0),
|
||||||
|
_obj_row(obj_id=2, distance_m=200.0),
|
||||||
|
_obj_row(obj_id=3, distance_m=300.0),
|
||||||
|
]
|
||||||
|
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_quarter", "exclude_obj_ids": [2]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
ids = [c["obj_id"] for c in resp.json()["competitors"]]
|
||||||
|
assert 2 not in ids
|
||||||
|
assert 1 in ids
|
||||||
|
assert 3 in ids
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_obj_class_filter() -> None:
|
||||||
|
"""obj_class_filter=economy — SQL получает параметр; Python-сторона не ломается."""
|
||||||
|
rows = [_obj_row(obj_id=10, obj_class="economy")]
|
||||||
|
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_quarter", "obj_class_filter": "economy"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
comps = resp.json()["competitors"]
|
||||||
|
assert len(comps) == 1
|
||||||
|
assert comps[0]["obj_class"] == "economy"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_time_window_velocity() -> None:
|
||||||
|
"""time_window влияет на velocity_per_month (last_month vs last_year)."""
|
||||||
|
# Здесь проверяем, что endpoint принимает оба варианта без ошибок
|
||||||
|
# и возвращает velocity из mock-строки (DB-расчёт мокирован).
|
||||||
|
rows_month = [_obj_row(obj_id=1, velocity=12.0)]
|
||||||
|
rows_year = [_obj_row(obj_id=1, velocity=3.0)]
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
# last_month
|
||||||
|
db_m = _make_db(coord=_coord_row(), obj_rows=rows_month)
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db_m)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp_m = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_month"},
|
||||||
|
)
|
||||||
|
assert resp_m.status_code == 200, resp_m.text
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
# last_year
|
||||||
|
db_y = _make_db(coord=_coord_row(), obj_rows=rows_year)
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db_y)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp_y = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_year"},
|
||||||
|
)
|
||||||
|
assert resp_y.status_code == 200, resp_y.text
|
||||||
|
v_month = resp_m.json()["competitors"][0]["velocity_per_month"]
|
||||||
|
v_year = resp_y.json()["competitors"][0]["velocity_per_month"]
|
||||||
|
# month velocity выше чем year в нашем моке
|
||||||
|
assert v_month > v_year
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_parcel_not_found_404() -> None:
|
||||||
|
"""Если центроид участка не найден → 404."""
|
||||||
|
db = _make_db(coord=None) # first() вернёт None
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/99:99:9999999:999/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_empty_radius() -> None:
|
||||||
|
"""Нет конкурентов в радиусе → пустой список + summary с нулями."""
|
||||||
|
db = _make_db(coord=_coord_row(), obj_rows=[])
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 0.1, "time_window": "last_quarter"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["competitors"] == []
|
||||||
|
assert body["summary"]["total_competitors"] == 0
|
||||||
|
assert body["summary"]["active_count"] == 0
|
||||||
|
assert body["summary"]["weighted_avg_velocity"] == pytest.approx(0.0)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_sold_pct_null() -> None:
|
||||||
|
"""sold_pct и flats_sold — None (MVP: данные недоступны из domrf_kn_objects).
|
||||||
|
|
||||||
|
Полный расчёт продаж требует JOIN с domrf_kn_flats COUNT WHERE status='sold'
|
||||||
|
— отложен за пределы текущего PR.
|
||||||
|
"""
|
||||||
|
rows = [_obj_row(obj_id=1, flat_count=200)]
|
||||||
|
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
comp = resp.json()["competitors"][0]
|
||||||
|
assert comp["flats_sold"] is None
|
||||||
|
assert comp["sold_pct"] is None
|
||||||
|
assert comp["flats_total"] == 200
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_competitors_is_active_flag() -> None:
|
||||||
|
"""is_active=True для sales/construction, False для completed/null."""
|
||||||
|
rows = [
|
||||||
|
_obj_row(obj_id=1, site_status="sales"),
|
||||||
|
_obj_row(obj_id=2, site_status="construction"),
|
||||||
|
_obj_row(obj_id=3, site_status="completed"),
|
||||||
|
]
|
||||||
|
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||||
|
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
|
||||||
|
assert comps[1]["is_active"] is True
|
||||||
|
assert comps[2]["is_active"] is True
|
||||||
|
assert comps[3]["is_active"] is False
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
Loading…
Add table
Reference in a new issue