feat: add area detail workspace

This commit is contained in:
2026-06-24 12:03:17 +08:00
parent db09615004
commit 6ca67e7555
6 changed files with 538 additions and 17 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -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))