feat: add area detail workspace
This commit is contained in:
@@ -5,13 +5,13 @@ use serde::Serialize;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::import_templates::{execute_import_payload, validate_import_payload};
|
||||
use crate::models::{
|
||||
AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
|
||||
AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
||||
CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest,
|
||||
ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun,
|
||||
IngestionRunQuery, MarketOverview, ModelRun, MonthQuery, Neighborhood, NeighborhoodQuery,
|
||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
||||
WatchlistItem,
|
||||
AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery,
|
||||
AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource,
|
||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||
MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
||||
RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
|
||||
};
|
||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||
use crate::state::AppState;
|
||||
@@ -233,7 +233,19 @@ pub async fn get_area_score_lineage(
|
||||
Query(query): Query<AreaScoreLineageQuery>,
|
||||
) -> ApiResult<Json<AreaScoreLineage>> {
|
||||
validate_month(&query.month)?;
|
||||
let lineage = sqlx::query_as::<_, AreaScoreLineage>(
|
||||
let lineage = fetch_area_score_lineage(&state, &area_id, &query.month)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("area score lineage not found".to_string()))?;
|
||||
|
||||
Ok(Json(lineage))
|
||||
}
|
||||
|
||||
async fn fetch_area_score_lineage(
|
||||
state: &AppState,
|
||||
area_id: &str,
|
||||
month: &str,
|
||||
) -> Result<Option<AreaScoreLineage>, sqlx::Error> {
|
||||
sqlx::query_as::<_, AreaScoreLineage>(
|
||||
r#"
|
||||
SELECT
|
||||
s.area_id,
|
||||
@@ -261,12 +273,151 @@ pub async fn get_area_score_lineage(
|
||||
"#,
|
||||
)
|
||||
.bind(area_id)
|
||||
.bind(query.month)
|
||||
.bind(month)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_area_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(area_id): Path<String>,
|
||||
Query(query): Query<MonthQuery>,
|
||||
) -> ApiResult<Json<AreaDetail>> {
|
||||
validate_month(&query.month)?;
|
||||
|
||||
let profile = sqlx::query_as::<_, AreaProfile>(
|
||||
r#"
|
||||
SELECT area_id, name, district, segment, notes
|
||||
FROM silver.areas
|
||||
WHERE area_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&area_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("area score lineage not found".to_string()))?;
|
||||
.ok_or_else(|| ApiError::BadRequest("area not found".to_string()))?;
|
||||
|
||||
Ok(Json(lineage))
|
||||
let current_score = sqlx::query_as::<_, AreaScore>(
|
||||
r#"
|
||||
SELECT
|
||||
area_id,
|
||||
name,
|
||||
district,
|
||||
segment,
|
||||
month,
|
||||
investment_score,
|
||||
recommendation,
|
||||
liquidity_score,
|
||||
momentum_score,
|
||||
rent_support_score,
|
||||
safety_margin_score,
|
||||
credit_support_score,
|
||||
supply_risk_score,
|
||||
valuation_pressure_score,
|
||||
transaction_count,
|
||||
transaction_price_psm,
|
||||
listing_count,
|
||||
listing_price_psm,
|
||||
rent_price_psm,
|
||||
median_days_on_market,
|
||||
annual_rent_yield_pct,
|
||||
listing_pressure_ratio,
|
||||
discount_pct,
|
||||
price_momentum_pct,
|
||||
volume_momentum_pct
|
||||
FROM gold.area_scores
|
||||
WHERE area_id = $1
|
||||
AND month = $2
|
||||
"#,
|
||||
)
|
||||
.bind(&area_id)
|
||||
.bind(&query.month)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let score_lineage = fetch_area_score_lineage(&state, &area_id, &query.month).await?;
|
||||
let monthly_metrics = sqlx::query_as::<_, AreaMonthlyMetric>(
|
||||
r#"
|
||||
SELECT
|
||||
area_id,
|
||||
month,
|
||||
transaction_count,
|
||||
transaction_price_psm,
|
||||
listing_count,
|
||||
listing_price_psm,
|
||||
median_days_on_market,
|
||||
rent_price_psm,
|
||||
new_supply_units,
|
||||
land_residential_gfa_sqm,
|
||||
mortgage_rate_pct,
|
||||
policy_signal,
|
||||
source,
|
||||
ingestion_run_id,
|
||||
raw_artifact_id
|
||||
FROM silver.area_monthly_metrics
|
||||
WHERE area_id = $1
|
||||
AND month <= $2
|
||||
ORDER BY month DESC
|
||||
LIMIT 12
|
||||
"#,
|
||||
)
|
||||
.bind(&area_id)
|
||||
.bind(&query.month)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let neighborhood_scores =
|
||||
fetch_neighborhood_scores_for_area(&state, &area_id, &query.month).await?;
|
||||
|
||||
Ok(Json(AreaDetail {
|
||||
profile,
|
||||
current_score,
|
||||
score_lineage,
|
||||
monthly_metrics,
|
||||
neighborhood_scores,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_neighborhood_scores_for_area(
|
||||
state: &AppState,
|
||||
area_id: &str,
|
||||
month: &str,
|
||||
) -> Result<Vec<NeighborhoodScore>, sqlx::Error> {
|
||||
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 area_id = $1
|
||||
AND month = $2
|
||||
ORDER BY investment_score DESC, neighborhood_id ASC
|
||||
"#,
|
||||
)
|
||||
.bind(area_id)
|
||||
.bind(month)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_neighborhoods(
|
||||
|
||||
@@ -194,6 +194,43 @@ pub struct AreaScoreLineage {
|
||||
pub score_computed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct AreaProfile {
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub district: String,
|
||||
pub segment: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct AreaMonthlyMetric {
|
||||
pub area_id: String,
|
||||
pub month: String,
|
||||
pub transaction_count: i32,
|
||||
pub transaction_price_psm: f64,
|
||||
pub listing_count: i32,
|
||||
pub listing_price_psm: f64,
|
||||
pub median_days_on_market: f64,
|
||||
pub rent_price_psm: f64,
|
||||
pub new_supply_units: i32,
|
||||
pub land_residential_gfa_sqm: f64,
|
||||
pub mortgage_rate_pct: f64,
|
||||
pub policy_signal: i32,
|
||||
pub source: String,
|
||||
pub ingestion_run_id: Option<i64>,
|
||||
pub raw_artifact_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaDetail {
|
||||
pub profile: AreaProfile,
|
||||
pub current_score: Option<AreaScore>,
|
||||
pub score_lineage: Option<AreaScoreLineage>,
|
||||
pub monthly_metrics: Vec<AreaMonthlyMetric>,
|
||||
pub neighborhood_scores: Vec<NeighborhoodScore>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct Neighborhood {
|
||||
pub neighborhood_id: String,
|
||||
|
||||
@@ -6,9 +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_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, 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;
|
||||
|
||||
@@ -47,6 +48,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/areas/{area_id}/score-lineage",
|
||||
get(get_area_score_lineage),
|
||||
)
|
||||
.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}", get(get_neighborhood))
|
||||
|
||||
Reference in New Issue
Block a user