feat: add neighborhood detail workspace

This commit is contained in:
2026-06-24 13:40:24 +08:00
parent 6ca67e7555
commit ff6e41a6e5
6 changed files with 551 additions and 26 deletions

View File

@@ -10,8 +10,9 @@ use crate::models::{
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
WatchlistItem,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -454,7 +455,118 @@ pub async fn get_neighborhood(
State(state): State<AppState>,
Path(neighborhood_id): Path<String>,
) -> ApiResult<Json<Neighborhood>> {
let neighborhood = sqlx::query_as::<_, Neighborhood>(
let neighborhood = fetch_neighborhood_profile(&state, &neighborhood_id)
.await?
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
Ok(Json(neighborhood))
}
pub async fn get_neighborhood_detail(
State(state): State<AppState>,
Path(neighborhood_id): Path<String>,
Query(query): Query<MonthQuery>,
) -> ApiResult<Json<NeighborhoodDetail>> {
validate_month(&query.month)?;
let profile = fetch_neighborhood_profile(&state, &neighborhood_id)
.await?
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
let current_score = sqlx::query_as::<_, NeighborhoodScore>(
r#"
SELECT
neighborhood_id,
area_id,
name,
area_name,
district,
month,
investment_score,
recommendation,
liquidity_score,
rent_support_score,
price_safety_score,
location_score,
building_age_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
discount_pct,
available_units
FROM gold.neighborhood_scores
WHERE neighborhood_id = $1
AND month = $2
"#,
)
.bind(&neighborhood_id)
.bind(&query.month)
.fetch_optional(&state.pool)
.await?;
let monthly_metrics = sqlx::query_as::<_, NeighborhoodMonthlyMetric>(
r#"
SELECT
neighborhood_id,
month,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
available_units,
source,
ingestion_run_id,
raw_artifact_id
FROM silver.neighborhood_monthly_metrics
WHERE neighborhood_id = $1
AND month <= $2
ORDER BY month DESC
LIMIT 12
"#,
)
.bind(&neighborhood_id)
.bind(&query.month)
.fetch_all(&state.pool)
.await?;
let watchlist_items = sqlx::query_as::<_, WatchlistItem>(
r#"
SELECT
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
notes
FROM app.watchlist_items
WHERE neighborhood_id = $1
AND status <> 'archived'
ORDER BY updated_at DESC, watchlist_item_id DESC
"#,
)
.bind(&neighborhood_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(NeighborhoodDetail {
profile,
current_score,
monthly_metrics,
watchlist_items,
}))
}
async fn fetch_neighborhood_profile(
state: &AppState,
neighborhood_id: &str,
) -> Result<Option<Neighborhood>, sqlx::Error> {
sqlx::query_as::<_, Neighborhood>(
r#"
SELECT
n.neighborhood_id,
@@ -474,10 +586,7 @@ pub async fn get_neighborhood(
)
.bind(neighborhood_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
Ok(Json(neighborhood))
.await
}
pub async fn list_neighborhood_scores(

View File

@@ -245,6 +245,22 @@ pub struct Neighborhood {
pub notes: String,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct NeighborhoodMonthlyMetric {
pub neighborhood_id: String,
pub month: String,
pub transaction_count: i32,
pub transaction_price_psm: f64,
pub listing_count: i32,
pub listing_price_psm: f64,
pub rent_price_psm: f64,
pub median_days_on_market: f64,
pub available_units: i32,
pub source: String,
pub ingestion_run_id: Option<i64>,
pub raw_artifact_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct NeighborhoodScore {
pub neighborhood_id: String,
@@ -271,6 +287,14 @@ pub struct NeighborhoodScore {
pub available_units: i32,
}
#[derive(Debug, Serialize)]
pub struct NeighborhoodDetail {
pub profile: Neighborhood,
pub current_score: Option<NeighborhoodScore>,
pub monthly_metrics: Vec<NeighborhoodMonthlyMetric>,
pub watchlist_items: Vec<WatchlistItem>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct DataSource {
pub source_id: i64,

View File

@@ -6,10 +6,10 @@ use tower_http::trace::TraceLayer;
use crate::handlers::{
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run,
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run,
get_area_detail, get_area_score_lineage, get_neighborhood, health, list_area_scores,
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods,
list_raw_artifacts, list_watchlist_items, market_overview, ready, update_watchlist_item,
validate_import,
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores,
list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready,
update_watchlist_item, validate_import,
};
use crate::state::AppState;
@@ -51,6 +51,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/areas/{area_id}/detail", get(get_area_detail))
.route("/neighborhoods", get(list_neighborhoods))
.route("/neighborhoods/scores", get(list_neighborhood_scores))
.route(
"/neighborhoods/{neighborhood_id}/detail",
get(get_neighborhood_detail),
)
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
.route(
"/watchlist",