feat: add report generation runs
This commit is contained in:
62
apps/api/migrations/202606240008_report_generation.sql
Normal file
62
apps/api/migrations/202606240008_report_generation.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS app.report_templates (
|
||||
template_id BIGSERIAL PRIMARY KEY,
|
||||
template_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
report_type TEXT NOT NULL CHECK (report_type IN ('monthly', 'area_special', 'neighborhood_memo')),
|
||||
cadence TEXT NOT NULL CHECK (cadence IN ('weekly', 'monthly', 'manual')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_template TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.report_generation_runs (
|
||||
generation_run_id BIGSERIAL PRIMARY KEY,
|
||||
template_id BIGINT NOT NULL REFERENCES app.report_templates(template_id),
|
||||
target_month TEXT NOT NULL CHECK (target_month ~ '^[0-9]{4}-[0-9]{2}$'),
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
|
||||
report_id BIGINT REFERENCES app.reports(report_id),
|
||||
parameters JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_report_generation_runs_month
|
||||
ON app.report_generation_runs(target_month, generation_run_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_report_generation_runs_template
|
||||
ON app.report_generation_runs(template_id, generation_run_id DESC);
|
||||
|
||||
INSERT INTO app.report_templates (
|
||||
template_key,
|
||||
name,
|
||||
report_type,
|
||||
cadence,
|
||||
description,
|
||||
body_template
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'monthly_market',
|
||||
'市场月报',
|
||||
'monthly',
|
||||
'monthly',
|
||||
'生成市场总览、板块评分、关键风险和下月跟踪事项。',
|
||||
'# 上海房市投资研究月报 {{month}}\n\n{{market_summary}}\n\n{{area_table}}\n\n{{risk_section}}\n\n{{next_actions}}'
|
||||
),
|
||||
(
|
||||
'weekly_watchlist',
|
||||
'观察池周报',
|
||||
'monthly',
|
||||
'weekly',
|
||||
'生成观察池状态、价格触发和待复核事项摘要。',
|
||||
'# 上海房市观察池周报 {{month}}\n\n{{watchlist_summary}}\n\n{{watchlist_table}}\n\n{{next_actions}}'
|
||||
)
|
||||
ON CONFLICT (template_key) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
report_type = EXCLUDED.report_type,
|
||||
cadence = EXCLUDED.cadence,
|
||||
description = EXCLUDED.description,
|
||||
body_template = EXCLUDED.body_template,
|
||||
updated_at = now();
|
||||
23
apps/api/migrations/202606240009_report_weekly_type.sql
Normal file
23
apps/api/migrations/202606240009_report_weekly_type.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE app.reports
|
||||
DROP CONSTRAINT IF EXISTS reports_report_type_check;
|
||||
|
||||
ALTER TABLE app.reports
|
||||
ADD CONSTRAINT reports_report_type_check
|
||||
CHECK (report_type IN ('monthly', 'weekly', 'area_special', 'neighborhood_memo'));
|
||||
|
||||
ALTER TABLE app.report_templates
|
||||
DROP CONSTRAINT IF EXISTS report_templates_report_type_check;
|
||||
|
||||
ALTER TABLE app.report_templates
|
||||
ADD CONSTRAINT report_templates_report_type_check
|
||||
CHECK (report_type IN ('monthly', 'weekly', 'area_special', 'neighborhood_memo'));
|
||||
|
||||
UPDATE app.report_templates
|
||||
SET report_type = 'weekly',
|
||||
updated_at = now()
|
||||
WHERE template_key = 'weekly_watchlist';
|
||||
|
||||
UPDATE app.reports
|
||||
SET report_type = 'weekly',
|
||||
updated_at = now()
|
||||
WHERE linked_metrics->>'template_key' = 'weekly_watchlist';
|
||||
@@ -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, ¶meters).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 {
|
||||
|
||||
@@ -74,6 +74,13 @@ pub struct ReportQuery {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReportGenerationRunQuery {
|
||||
pub target_month: Option<String>,
|
||||
pub template_key: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawArtifactQuery {
|
||||
pub source_id: Option<i64>,
|
||||
@@ -627,10 +634,69 @@ pub struct CreateReport {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct ReportTemplate {
|
||||
pub template_id: i64,
|
||||
pub template_key: String,
|
||||
pub name: String,
|
||||
pub report_type: String,
|
||||
pub cadence: String,
|
||||
pub description: String,
|
||||
pub body_template: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct ReportGenerationRun {
|
||||
pub generation_run_id: i64,
|
||||
pub template_id: i64,
|
||||
pub template_key: String,
|
||||
pub template_name: String,
|
||||
pub target_month: String,
|
||||
pub status: String,
|
||||
pub report_id: Option<i64>,
|
||||
pub report_title: Option<String>,
|
||||
pub parameters: Value,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateReportGenerationRun {
|
||||
pub template_key: String,
|
||||
pub target_month: String,
|
||||
pub parameters: Option<Value>,
|
||||
}
|
||||
|
||||
impl CreateReportGenerationRun {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.template_key.trim().is_empty() {
|
||||
return Err("template_key is required");
|
||||
}
|
||||
if has_sensitive_text(&self.template_key) {
|
||||
return Err("payload contains sensitive text");
|
||||
}
|
||||
if let Some(parameters) = &self.parameters {
|
||||
if !parameters.is_object() {
|
||||
return Err("parameters must be a JSON object");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReportGenerationRunResponse {
|
||||
pub generation_run: ReportGenerationRun,
|
||||
pub report: Option<Report>,
|
||||
}
|
||||
|
||||
impl CreateReport {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if !is_report_type(&self.report_type) {
|
||||
return Err("report_type must be monthly, area_special, or neighborhood_memo");
|
||||
return Err("report_type must be monthly, weekly, area_special, or neighborhood_memo");
|
||||
}
|
||||
if self.title.trim().is_empty() {
|
||||
return Err("title is required");
|
||||
@@ -1033,7 +1099,7 @@ fn is_watchlist_trigger_operator(operator: &str) -> bool {
|
||||
fn is_report_type(report_type: &str) -> bool {
|
||||
matches!(
|
||||
report_type,
|
||||
"monthly" | "area_special" | "neighborhood_memo"
|
||||
"monthly" | "weekly" | "area_special" | "neighborhood_memo"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ use tower_http::trace::TraceLayer;
|
||||
use crate::handlers::{
|
||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
||||
create_data_source, create_ingestion_run, create_raw_artifact, create_report,
|
||||
create_scenario_run, create_watchlist_event, create_watchlist_item, diff_area_score_model_runs,
|
||||
execute_import, finish_ingestion_run, get_area_detail, get_area_diagnostics,
|
||||
get_area_score_lineage, get_neighborhood, get_neighborhood_detail, get_report,
|
||||
get_similar_neighborhoods, health, list_area_scores, list_data_sources, list_ingestion_runs,
|
||||
list_model_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
||||
create_report_generation_run, create_scenario_run, create_watchlist_event,
|
||||
create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
|
||||
get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
|
||||
get_neighborhood_detail, get_report, get_similar_neighborhoods, health, list_area_scores,
|
||||
list_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores,
|
||||
list_neighborhoods, list_raw_artifacts, list_report_generation_runs, list_report_templates,
|
||||
list_reports, list_scenario_runs, list_watchlist_events, list_watchlist_items, market_overview,
|
||||
ready, update_watchlist_item, validate_import,
|
||||
};
|
||||
@@ -88,7 +89,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
get(list_scenario_runs).post(create_scenario_run),
|
||||
)
|
||||
.route("/reports", get(list_reports).post(create_report))
|
||||
.route("/reports/{report_id}", get(get_report)),
|
||||
.route("/reports/{report_id}", get(get_report))
|
||||
.route("/report-templates", get(list_report_templates))
|
||||
.route(
|
||||
"/report-generation-runs",
|
||||
get(list_report_generation_runs).post(create_report_generation_run),
|
||||
),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
|
||||
Reference in New Issue
Block a user