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(