feat: add report generation runs

This commit is contained in:
2026-06-24 18:00:25 +08:00
parent 049d6a711e
commit 66e2d66055
8 changed files with 1034 additions and 58 deletions

View File

@@ -9,16 +9,18 @@ use crate::models::{
AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
CreateRawArtifact, CreateReport, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem,
DataSource, DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest,
ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun,
IngestionRunQuery, MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery,
Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail,
NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
RawArtifact, RawArtifactQuery, Report, ReportQuery, ScenarioAreaResult, ScenarioRun,
ScenarioRunQuery, ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood,
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse,
UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery,
CreateRawArtifact, CreateReport, CreateReportGenerationRun, CreateScenarioRun,
CreateWatchlistEvent, CreateWatchlistItem, DataSource, DiagnosticMetric, FinishIngestionRun,
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, Report,
ReportGenerationRun, ReportGenerationRunQuery, ReportGenerationRunResponse, ReportQuery,
ReportTemplate, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery, ScenarioRunResponse,
ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery,
SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent, WatchlistItem,
WatchlistQuery,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -1907,42 +1909,162 @@ pub async fn create_report(
}
let mut tx = state.pool.begin().await?;
let report_id = sqlx::query_scalar::<_, i64>(
r#"
INSERT INTO app.reports (
report_type,
title,
target_month,
area_id,
neighborhood_id,
summary,
content_markdown,
linked_metrics,
status
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, ''), $7, $8, COALESCE($9, 'draft'))
RETURNING report_id
"#,
let report = insert_report_from_payload(
&mut tx,
CreateReport {
linked_metrics: Some(linked_metrics),
..payload
},
)
.bind(payload.report_type.trim().to_string())
.bind(payload.title.trim().to_string())
.bind(payload.target_month.clone())
.bind(payload.area_id.clone())
.bind(payload.neighborhood_id.clone())
.bind(payload.summary.clone())
.bind(payload.content_markdown.trim().to_string())
.bind(linked_metrics)
.bind(payload.status.clone())
.fetch_one(&mut *tx)
.await?;
let report = fetch_report_by_id(&mut tx, report_id)
.await?
.ok_or_else(|| ApiError::BadRequest("report not found".to_string()))?;
tx.commit().await?;
Ok(Json(report))
}
pub async fn list_report_templates(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<ReportTemplate>>> {
let templates = sqlx::query_as::<_, ReportTemplate>(
r#"
SELECT
template_id,
template_key,
name,
report_type,
cadence,
description,
body_template,
created_at::text AS created_at,
updated_at::text AS updated_at
FROM app.report_templates
ORDER BY cadence, template_key
"#,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(templates))
}
pub async fn list_report_generation_runs(
State(state): State<AppState>,
Query(query): Query<ReportGenerationRunQuery>,
) -> ApiResult<Json<Vec<ReportGenerationRun>>> {
validate_report_generation_query(&query)?;
let runs = sqlx::query_as::<_, ReportGenerationRun>(
r#"
SELECT
r.generation_run_id,
r.template_id,
t.template_key,
t.name AS template_name,
r.target_month,
r.status,
r.report_id,
p.title AS report_title,
r.parameters,
r.error_message,
r.started_at::text AS started_at,
r.finished_at::text AS finished_at
FROM app.report_generation_runs r
JOIN app.report_templates t ON t.template_id = r.template_id
LEFT JOIN app.reports p ON p.report_id = r.report_id
WHERE ($1::text IS NULL OR r.target_month = $1)
AND ($2::text IS NULL OR t.template_key = $2)
AND ($3::text IS NULL OR r.status = $3)
ORDER BY r.generation_run_id DESC
LIMIT 80
"#,
)
.bind(query.target_month)
.bind(query.template_key)
.bind(query.status)
.fetch_all(&state.pool)
.await?;
Ok(Json(runs))
}
pub async fn create_report_generation_run(
State(state): State<AppState>,
Json(payload): Json<CreateReportGenerationRun>,
) -> ApiResult<Json<ReportGenerationRunResponse>> {
validate_month(&payload.target_month)?;
payload
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
validate_identifier(&payload.template_key)?;
let parameters = payload
.parameters
.clone()
.unwrap_or_else(|| serde_json::json!({}));
let mut tx = state.pool.begin().await?;
let template = fetch_report_template(&mut tx, &payload.template_key)
.await?
.ok_or_else(|| ApiError::BadRequest("report template not found".to_string()))?;
let generation_run_id = sqlx::query_scalar::<_, i64>(
r#"
INSERT INTO app.report_generation_runs (
template_id,
target_month,
status,
parameters
)
VALUES ($1, $2, 'running', $3)
RETURNING generation_run_id
"#,
)
.bind(template.template_id)
.bind(payload.target_month.trim().to_string())
.bind(parameters.clone())
.fetch_one(&mut *tx)
.await?;
let generated =
build_generated_report(&mut tx, &template, &payload.target_month, &parameters).await;
match generated {
Ok(create_report_payload) => {
let report = insert_report_from_payload(&mut tx, create_report_payload).await?;
finish_report_generation_run(
&mut tx,
generation_run_id,
"succeeded",
Some(report.report_id),
None,
)
.await?;
let generation_run = fetch_report_generation_run_by_id(&mut tx, generation_run_id)
.await?
.ok_or_else(|| ApiError::BadRequest("generation run not found".to_string()))?;
tx.commit().await?;
Ok(Json(ReportGenerationRunResponse {
generation_run,
report: Some(report),
}))
}
Err(message) => {
finish_report_generation_run(
&mut tx,
generation_run_id,
"failed",
None,
Some(&message),
)
.await?;
let generation_run = fetch_report_generation_run_by_id(&mut tx, generation_run_id)
.await?
.ok_or_else(|| ApiError::BadRequest("generation run not found".to_string()))?;
tx.commit().await?;
Ok(Json(ReportGenerationRunResponse {
generation_run,
report: None,
}))
}
}
}
async fn insert_watchlist_event(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
watchlist_item_id: i64,
@@ -2003,6 +2125,406 @@ async fn fetch_report_by_id(
.await
}
async fn fetch_report_template(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template_key: &str,
) -> Result<Option<ReportTemplate>, sqlx::Error> {
sqlx::query_as::<_, ReportTemplate>(
r#"
SELECT
template_id,
template_key,
name,
report_type,
cadence,
description,
body_template,
created_at::text AS created_at,
updated_at::text AS updated_at
FROM app.report_templates
WHERE template_key = $1
"#,
)
.bind(template_key)
.fetch_optional(&mut **tx)
.await
}
async fn fetch_report_generation_run_by_id(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
generation_run_id: i64,
) -> Result<Option<ReportGenerationRun>, sqlx::Error> {
sqlx::query_as::<_, ReportGenerationRun>(
r#"
SELECT
r.generation_run_id,
r.template_id,
t.template_key,
t.name AS template_name,
r.target_month,
r.status,
r.report_id,
p.title AS report_title,
r.parameters,
r.error_message,
r.started_at::text AS started_at,
r.finished_at::text AS finished_at
FROM app.report_generation_runs r
JOIN app.report_templates t ON t.template_id = r.template_id
LEFT JOIN app.reports p ON p.report_id = r.report_id
WHERE r.generation_run_id = $1
"#,
)
.bind(generation_run_id)
.fetch_optional(&mut **tx)
.await
}
async fn finish_report_generation_run(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
generation_run_id: i64,
status: &str,
report_id: Option<i64>,
error_message: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE app.report_generation_runs
SET status = $2,
report_id = $3,
error_message = $4,
finished_at = now()
WHERE generation_run_id = $1
"#,
)
.bind(generation_run_id)
.bind(status)
.bind(report_id)
.bind(error_message)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_report_from_payload(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
payload: CreateReport,
) -> Result<Report, sqlx::Error> {
let report_id = sqlx::query_scalar::<_, i64>(
r#"
INSERT INTO app.reports (
report_type,
title,
target_month,
area_id,
neighborhood_id,
summary,
content_markdown,
linked_metrics,
status
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, ''), $7, $8, COALESCE($9, 'draft'))
RETURNING report_id
"#,
)
.bind(payload.report_type.trim().to_string())
.bind(payload.title.trim().to_string())
.bind(payload.target_month.clone())
.bind(payload.area_id.clone())
.bind(payload.neighborhood_id.clone())
.bind(payload.summary.clone())
.bind(payload.content_markdown.trim().to_string())
.bind(
payload
.linked_metrics
.unwrap_or_else(|| serde_json::json!({})),
)
.bind(payload.status.clone())
.fetch_one(&mut **tx)
.await?;
fetch_report_by_id(tx, report_id)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
async fn build_generated_report(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template: &ReportTemplate,
target_month: &str,
parameters: &serde_json::Value,
) -> Result<CreateReport, String> {
match template.template_key.as_str() {
"monthly_market" => {
build_monthly_market_report(tx, template, target_month, parameters).await
}
"weekly_watchlist" => {
build_weekly_watchlist_report(tx, template, target_month, parameters).await
}
other => Err(format!("template '{other}' is not supported by generator")),
}
}
async fn build_monthly_market_report(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template: &ReportTemplate,
target_month: &str,
parameters: &serde_json::Value,
) -> Result<CreateReport, String> {
let scores = fetch_area_scores_in_tx(tx, target_month)
.await
.map_err(|error| format!("failed to fetch area scores: {error}"))?;
if scores.is_empty() {
return Err(format!("no area scores found for month {target_month}"));
}
let top = scores
.first()
.ok_or_else(|| "no top area found".to_string())?;
let weakest = scores
.last()
.ok_or_else(|| "no weakest area found".to_string())?;
let high_supply = scores
.iter()
.max_by(|left, right| {
left.supply_risk_score
.partial_cmp(&right.supply_risk_score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.ok_or_else(|| "no supply risk area found".to_string())?;
let avg_score = average(scores.iter().map(|score| score.investment_score));
let avg_yield = average(scores.iter().map(|score| score.annual_rent_yield_pct));
let market_summary = format!(
"## 核心结论\n\n- 综合评分最高板块:{}{}{})。\n- 综合评分最低板块:{}{}{})。\n- 供应压力最高板块:{}(供应风险 {})。\n- 样本平均投资观察评分:{};样本平均年化租金收益率:{}%。",
top.name,
format_score(top.investment_score),
top.recommendation,
weakest.name,
format_score(weakest.investment_score),
weakest.recommendation,
high_supply.name,
format_score(high_supply.supply_risk_score),
format_score(avg_score),
round_to(avg_yield, 2)
);
let area_table = monthly_area_table(&scores);
let risk_section = format!(
"## 风险解释\n\n- {} 的供应风险最高,需要跟踪新增供应和挂牌去化。\n- {} 综合评分最低,进入谨慎复核列表。\n- 自动报告只汇总结构化指标,具体买卖动作仍需人工复核。",
high_supply.name, weakest.name
);
let next_actions = "## 下月跟踪\n\n- 复核高分板块内小区分化。\n- 跟踪观察池价格触发和成交量变化。\n- 对政策和利率变化进行情景推演。";
let content = template
.body_template
.replace("{{month}}", target_month)
.replace("{{market_summary}}", &market_summary)
.replace("{{area_table}}", &area_table)
.replace("{{risk_section}}", &risk_section)
.replace("{{next_actions}}", next_actions);
Ok(CreateReport {
report_type: template.report_type.clone(),
title: format!("上海房市投资研究月报 {target_month}"),
target_month: Some(target_month.to_string()),
area_id: None,
neighborhood_id: None,
summary: Some(format!(
"最高评分 {},最低评分 {},供应压力最高 {}",
top.name, weakest.name, high_supply.name
)),
content_markdown: content,
linked_metrics: Some(serde_json::json!({
"template_key": template.template_key,
"month": target_month,
"top_area_id": top.area_id,
"weakest_area_id": weakest.area_id,
"highest_supply_risk_area_id": high_supply.area_id,
"area_count": scores.len(),
"parameters": parameters
})),
status: Some("published".to_string()),
})
}
async fn build_weekly_watchlist_report(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template: &ReportTemplate,
target_month: &str,
parameters: &serde_json::Value,
) -> Result<CreateReport, String> {
let items = fetch_watchlist_items_in_tx(tx)
.await
.map_err(|error| format!("failed to fetch watchlist items: {error}"))?;
let active_count = items.iter().filter(|item| item.status == "active").count();
let paused_count = items.iter().filter(|item| item.status == "paused").count();
let high_priority_count = items.iter().filter(|item| item.priority == "high").count();
let watchlist_summary = format!(
"## 观察池概览\n\n- 当前观察标的 {} 个,其中 active {} 个、paused {} 个。\n- 高优先级标的 {} 个。\n- 本周重点复核价格触发、成交量变化和最新备注。",
items.len(),
active_count,
paused_count,
high_priority_count
);
let watchlist_table = watchlist_report_table(&items);
let next_actions = "## 下一步动作\n\n- 对 high 优先级标的补充人工复核记录。\n- 检查触发价附近标的是否需要调整观察价。\n- 将异常价格变化同步到预警模块。";
let content = template
.body_template
.replace("{{month}}", target_month)
.replace("{{watchlist_summary}}", &watchlist_summary)
.replace("{{watchlist_table}}", &watchlist_table)
.replace("{{next_actions}}", next_actions);
Ok(CreateReport {
report_type: template.report_type.clone(),
title: format!("上海房市观察池周报 {target_month}"),
target_month: Some(target_month.to_string()),
area_id: None,
neighborhood_id: None,
summary: Some(format!(
"观察标的 {} 个,高优先级 {} 个。",
items.len(),
high_priority_count
)),
content_markdown: content,
linked_metrics: Some(serde_json::json!({
"template_key": template.template_key,
"month": target_month,
"watchlist_count": items.len(),
"active_count": active_count,
"high_priority_count": high_priority_count,
"parameters": parameters
})),
status: Some("published".to_string()),
})
}
async fn fetch_area_scores_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
month: &str,
) -> Result<Vec<AreaScore>, sqlx::Error> {
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 month = $1
ORDER BY investment_score DESC, area_id ASC
"#,
)
.bind(month)
.fetch_all(&mut **tx)
.await
}
async fn fetch_watchlist_items_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Vec<WatchlistItem>, sqlx::Error> {
sqlx::query_as::<_, WatchlistItem>(
r#"
SELECT
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
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'
ORDER BY priority DESC, updated_at DESC, watchlist_item_id DESC
LIMIT 100
"#,
)
.fetch_all(&mut **tx)
.await
}
fn monthly_area_table(scores: &[AreaScore]) -> String {
let mut lines = vec![
"## 板块评分".to_string(),
String::new(),
"| 排名 | 板块 | 行政区 | 综合分 | 建议 | 成交均价/㎡ | 成交套数 | 供应风险 |".to_string(),
"| --- | --- | --- | ---: | --- | ---: | ---: | ---: |".to_string(),
];
for (index, score) in scores.iter().enumerate() {
lines.push(format!(
"| {} | {} | {} | {} | {} | {:.0} | {} | {} |",
index + 1,
score.name,
score.district,
format_score(score.investment_score),
score.recommendation,
score.transaction_price_psm,
score.transaction_count,
format_score(score.supply_risk_score)
));
}
lines.join("\n")
}
fn watchlist_report_table(items: &[WatchlistItem]) -> String {
let mut lines = vec![
"## 观察池明细".to_string(),
String::new(),
"| ID | 类型 | 状态 | 优先级 | 目标价/㎡ | 触发价/㎡ | 标签 |".to_string(),
"| ---: | --- | --- | --- | ---: | ---: | --- |".to_string(),
];
for item in items.iter().take(30) {
lines.push(format!(
"| {} | {} | {} | {} | {} | {} | {} |",
item.watchlist_item_id,
item.neighborhood_id
.as_deref()
.or(item.area_id.as_deref())
.unwrap_or("-"),
item.status,
item.priority,
format_optional_price(item.target_price_psm),
format_optional_price(item.trigger_price_psm),
item.label
));
}
lines.join("\n")
}
fn format_score(value: f64) -> String {
format!("{:.1}", round_to(value, 1))
}
fn format_optional_price(value: Option<f64>) -> String {
value
.map(|item| format!("{:.0}", item))
.unwrap_or_else(|| "-".to_string())
}
fn validate_report_query(query: &ReportQuery) -> ApiResult<()> {
if let Some(report_type) = query.report_type.as_deref() {
validate_report_type(report_type)?;
@@ -2042,6 +2564,19 @@ fn validate_create_report(payload: &CreateReport) -> ApiResult<()> {
Ok(())
}
fn validate_report_generation_query(query: &ReportGenerationRunQuery) -> ApiResult<()> {
if let Some(month) = query.target_month.as_deref() {
validate_month(month)?;
}
if let Some(template_key) = query.template_key.as_deref() {
validate_identifier(template_key)?;
}
if let Some(status) = query.status.as_deref() {
validate_generation_status(status)?;
}
Ok(())
}
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
sqlx::query_as::<_, AreaScore>(
r#"
@@ -3032,11 +3567,14 @@ fn validate_identifier(value: &str) -> ApiResult<()> {
}
fn validate_report_type(value: &str) -> ApiResult<()> {
if matches!(value, "monthly" | "area_special" | "neighborhood_memo") {
if matches!(
value,
"monthly" | "weekly" | "area_special" | "neighborhood_memo"
) {
Ok(())
} else {
Err(ApiError::BadRequest(
"report_type must be monthly, area_special, or neighborhood_memo".to_string(),
"report_type must be monthly, weekly, area_special, or neighborhood_memo".to_string(),
))
}
}
@@ -3051,6 +3589,16 @@ fn validate_report_status(value: &str) -> ApiResult<()> {
}
}
fn validate_generation_status(value: &str) -> ApiResult<()> {
if matches!(value, "running" | "succeeded" | "failed") {
Ok(())
} else {
Err(ApiError::BadRequest(
"status must be running, succeeded, or failed".to_string(),
))
}
}
fn has_sensitive_text(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.contains("password")
@@ -3065,9 +3613,9 @@ fn has_sensitive_text(value: &str) -> bool {
#[cfg(test)]
mod tests {
use crate::models::{
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport, CreateScenarioRun,
CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate,
UpdateWatchlistItem,
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport,
CreateReportGenerationRun, CreateScenarioRun, CreateWatchlistItem, FinishIngestionRun,
ScenarioRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem,
};
use super::{
@@ -3311,6 +3859,33 @@ mod tests {
.is_err());
}
#[test]
fn validates_report_generation_payload() {
assert!(CreateReportGenerationRun {
template_key: "monthly_market".to_string(),
target_month: "2026-05".to_string(),
parameters: Some(serde_json::json!({"scope": "all"})),
}
.validate()
.is_ok());
assert!(CreateReportGenerationRun {
template_key: "".to_string(),
target_month: "2026-05".to_string(),
parameters: None,
}
.validate()
.is_err());
assert!(CreateReportGenerationRun {
template_key: "monthly_market".to_string(),
target_month: "2026-05".to_string(),
parameters: Some(serde_json::json!(["bad"])),
}
.validate()
.is_err());
}
#[test]
fn validates_scenario_run_payload() {
assert!(CreateScenarioRun {