feat: enhance watchlist workflow

This commit is contained in:
2026-06-24 14:43:58 +08:00
parent f8fb09d2a9
commit f1ad5a7d15
7 changed files with 829 additions and 68 deletions

View File

@@ -8,12 +8,13 @@ use crate::models::{
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
CreateIngestionRun, CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource,
FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistEvent,
WatchlistItem, WatchlistQuery,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -660,7 +661,14 @@ pub async fn get_neighborhood_detail(
area_id,
target_price_psm,
status,
notes
label,
priority,
trigger_operator,
trigger_price_psm,
notes,
created_at::text AS created_at,
updated_at::text AS updated_at,
last_reviewed_at::text AS last_reviewed_at
FROM app.watchlist_items
WHERE neighborhood_id = $1
AND status <> 'archived'
@@ -1095,6 +1103,7 @@ pub async fn create_raw_artifact(
pub async fn list_watchlist_items(
State(state): State<AppState>,
Query(query): Query<WatchlistQuery>,
) -> ApiResult<Json<Vec<WatchlistItem>>> {
let items = sqlx::query_as::<_, WatchlistItem>(
r#"
@@ -1104,12 +1113,27 @@ pub async fn list_watchlist_items(
area_id,
target_price_psm,
status,
notes
label,
priority,
trigger_operator,
trigger_price_psm,
notes,
created_at::text AS created_at,
updated_at::text AS updated_at,
last_reviewed_at::text AS last_reviewed_at
FROM app.watchlist_items
WHERE status <> 'archived'
WHERE ($1::text IS NULL OR status = $1)
AND ($1::text IS NOT NULL OR status <> 'archived')
AND ($2::text IS NULL OR area_id = $2)
AND ($3::text IS NULL OR neighborhood_id = $3)
AND ($4::text IS NULL OR label = $4)
ORDER BY updated_at DESC, watchlist_item_id DESC
"#,
)
.bind(query.status)
.bind(query.area_id)
.bind(query.neighborhood_id)
.bind(query.label)
.fetch_all(&state.pool)
.await?;
@@ -1124,30 +1148,57 @@ pub async fn create_watchlist_item(
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
let mut tx = state.pool.begin().await?;
let item = sqlx::query_as::<_, WatchlistItem>(
r#"
INSERT INTO app.watchlist_items (
neighborhood_id,
area_id,
target_price_psm,
label,
priority,
trigger_operator,
trigger_price_psm,
notes
)
VALUES ($1, $2, $3, COALESCE($4, ''))
VALUES (
$1,
$2,
$3,
COALESCE($4, 'general'),
COALESCE($5, 'medium'),
COALESCE($6, 'lte'),
$7,
COALESCE($8, '')
)
RETURNING
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
notes
label,
priority,
trigger_operator,
trigger_price_psm,
notes,
created_at::text AS created_at,
updated_at::text AS updated_at,
last_reviewed_at::text AS last_reviewed_at
"#,
)
.bind(payload.neighborhood_id)
.bind(payload.area_id)
.bind(payload.target_price_psm)
.bind(payload.label)
.bind(payload.priority)
.bind(payload.trigger_operator)
.bind(payload.trigger_price_psm)
.bind(payload.notes)
.fetch_one(&state.pool)
.fetch_one(&mut *tx)
.await?;
insert_watchlist_event(&mut tx, item.watchlist_item_id, "created", "创建观察标的").await?;
tx.commit().await?;
Ok(Json(item))
}
@@ -1161,12 +1212,18 @@ pub async fn update_watchlist_item(
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
let mut tx = state.pool.begin().await?;
let item = sqlx::query_as::<_, WatchlistItem>(
r#"
UPDATE app.watchlist_items
SET target_price_psm = COALESCE($2, target_price_psm),
status = COALESCE($3, status),
notes = COALESCE($4, notes),
label = COALESCE($4, label),
priority = COALESCE($5, priority),
trigger_operator = COALESCE($6, trigger_operator),
trigger_price_psm = COALESCE($7, trigger_price_psm),
notes = COALESCE($8, notes),
last_reviewed_at = CASE WHEN $9 THEN now() ELSE last_reviewed_at END,
updated_at = now()
WHERE watchlist_item_id = $1
RETURNING
@@ -1175,16 +1232,44 @@ pub async fn update_watchlist_item(
area_id,
target_price_psm,
status,
notes
label,
priority,
trigger_operator,
trigger_price_psm,
notes,
created_at::text AS created_at,
updated_at::text AS updated_at,
last_reviewed_at::text AS last_reviewed_at
"#,
)
.bind(watchlist_item_id)
.bind(payload.target_price_psm)
.bind(payload.status)
.bind(payload.status.clone())
.bind(payload.label)
.bind(payload.priority)
.bind(payload.trigger_operator)
.bind(payload.trigger_price_psm)
.bind(payload.notes)
.fetch_optional(&state.pool)
.bind(payload.mark_reviewed.unwrap_or(false))
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
insert_watchlist_event(
&mut tx,
item.watchlist_item_id,
if payload.mark_reviewed.unwrap_or(false) {
"reviewed"
} else {
"updated"
},
if payload.mark_reviewed.unwrap_or(false) {
"完成一次人工复核"
} else {
"更新观察标的"
},
)
.await?;
tx.commit().await?;
Ok(Json(item))
}
@@ -1193,6 +1278,7 @@ pub async fn archive_watchlist_item(
State(state): State<AppState>,
Path(watchlist_item_id): Path<i64>,
) -> ApiResult<Json<WatchlistItem>> {
let mut tx = state.pool.begin().await?;
let item = sqlx::query_as::<_, WatchlistItem>(
r#"
UPDATE app.watchlist_items
@@ -1205,17 +1291,109 @@ pub async fn archive_watchlist_item(
area_id,
target_price_psm,
status,
notes
label,
priority,
trigger_operator,
trigger_price_psm,
notes,
created_at::text AS created_at,
updated_at::text AS updated_at,
last_reviewed_at::text AS last_reviewed_at
"#,
)
.bind(watchlist_item_id)
.fetch_optional(&state.pool)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
insert_watchlist_event(&mut tx, item.watchlist_item_id, "archived", "归档观察标的").await?;
tx.commit().await?;
Ok(Json(item))
}
pub async fn list_watchlist_events(
State(state): State<AppState>,
Path(watchlist_item_id): Path<i64>,
) -> ApiResult<Json<Vec<WatchlistEvent>>> {
let events = sqlx::query_as::<_, WatchlistEvent>(
r#"
SELECT
event_id,
watchlist_item_id,
event_type,
summary,
created_at::text AS created_at
FROM app.watchlist_events
WHERE watchlist_item_id = $1
ORDER BY created_at DESC, event_id DESC
LIMIT 50
"#,
)
.bind(watchlist_item_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(events))
}
pub async fn create_watchlist_event(
State(state): State<AppState>,
Path(watchlist_item_id): Path<i64>,
Json(payload): Json<CreateWatchlistEvent>,
) -> ApiResult<Json<WatchlistEvent>> {
payload
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
let event = sqlx::query_as::<_, WatchlistEvent>(
r#"
INSERT INTO app.watchlist_events (
watchlist_item_id,
event_type,
summary
)
VALUES ($1, $2, $3)
RETURNING
event_id,
watchlist_item_id,
event_type,
summary,
created_at::text AS created_at
"#,
)
.bind(watchlist_item_id)
.bind(payload.event_type.trim().to_string())
.bind(payload.summary.trim().to_string())
.fetch_one(&state.pool)
.await?;
Ok(Json(event))
}
async fn insert_watchlist_event(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
watchlist_item_id: i64,
event_type: &str,
summary: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO app.watchlist_events (
watchlist_item_id,
event_type,
summary
)
VALUES ($1, $2, $3)
"#,
)
.bind(watchlist_item_id)
.bind(event_type)
.bind(summary)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
sqlx::query_as::<_, AreaScore>(
r#"
@@ -1788,6 +1966,10 @@ mod tests {
neighborhood_id: None,
area_id: None,
target_price_psm: None,
label: None,
priority: None,
trigger_operator: None,
trigger_price_psm: None,
notes: None,
}
.validate()
@@ -1797,6 +1979,10 @@ mod tests {
neighborhood_id: None,
area_id: Some("zhangjiang".to_string()),
target_price_psm: Some(80_000.0),
label: Some("核心".to_string()),
priority: Some("high".to_string()),
trigger_operator: Some("lte".to_string()),
trigger_price_psm: Some(76_000.0),
notes: None,
}
.validate()
@@ -1808,7 +1994,12 @@ mod tests {
assert!(UpdateWatchlistItem {
target_price_psm: Some(-1.0),
status: None,
label: None,
priority: None,
trigger_operator: None,
trigger_price_psm: None,
notes: None,
mark_reviewed: None,
}
.validate()
.is_err());
@@ -1816,7 +2007,12 @@ mod tests {
assert!(UpdateWatchlistItem {
target_price_psm: None,
status: Some("unknown".to_string()),
label: None,
priority: None,
trigger_operator: None,
trigger_price_psm: None,
notes: None,
mark_reviewed: None,
}
.validate()
.is_err());
@@ -1824,7 +2020,12 @@ mod tests {
assert!(UpdateWatchlistItem {
target_price_psm: Some(80_000.0),
status: Some("active".to_string()),
label: Some("核心".to_string()),
priority: Some("high".to_string()),
trigger_operator: Some("lte".to_string()),
trigger_price_psm: Some(76_000.0),
notes: Some("跟踪产业兑现和挂牌变化".to_string()),
mark_reviewed: Some(true),
}
.validate()
.is_ok());