From 66e2d660559eb77b5145bac35bbaa6bc43a8fc25 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Wed, 24 Jun 2026 18:00:25 +0800 Subject: [PATCH] feat: add report generation runs --- .../202606240008_report_generation.sql | 62 ++ .../202606240009_report_weekly_type.sql | 23 + apps/api/src/handlers.rs | 663 ++++++++++++++++-- apps/api/src/models.rs | 70 +- apps/api/src/routes.rs | 18 +- .../components/dashboard/market-dashboard.tsx | 176 ++++- apps/web/src/lib/api.ts | 68 +- docs/product_roadmap.md | 12 +- 8 files changed, 1034 insertions(+), 58 deletions(-) create mode 100644 apps/api/migrations/202606240008_report_generation.sql create mode 100644 apps/api/migrations/202606240009_report_weekly_type.sql diff --git a/apps/api/migrations/202606240008_report_generation.sql b/apps/api/migrations/202606240008_report_generation.sql new file mode 100644 index 0000000..a90c8b4 --- /dev/null +++ b/apps/api/migrations/202606240008_report_generation.sql @@ -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(); diff --git a/apps/api/migrations/202606240009_report_weekly_type.sql b/apps/api/migrations/202606240009_report_weekly_type.sql new file mode 100644 index 0000000..12d1a0c --- /dev/null +++ b/apps/api/migrations/202606240009_report_weekly_type.sql @@ -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'; diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index 8d37223..0d506cd 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -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, +) -> ApiResult>> { + 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, + Query(query): Query, +) -> ApiResult>> { + 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, + Json(payload): Json, +) -> ApiResult> { + 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, 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, 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, + 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 { + 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 { + 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 { + 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 { + 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, 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, 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) -> 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, 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 { diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 5b46153..18a81a0 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -74,6 +74,13 @@ pub struct ReportQuery { pub status: Option, } +#[derive(Debug, Deserialize)] +pub struct ReportGenerationRunQuery { + pub target_month: Option, + pub template_key: Option, + pub status: Option, +} + #[derive(Debug, Deserialize)] pub struct RawArtifactQuery { pub source_id: Option, @@ -627,10 +634,69 @@ pub struct CreateReport { pub status: Option, } +#[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, + pub report_title: Option, + pub parameters: Value, + pub error_message: Option, + pub started_at: String, + pub finished_at: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateReportGenerationRun { + pub template_key: String, + pub target_month: String, + pub parameters: Option, +} + +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, +} + 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" ) } diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 37a0941..6729933 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -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()) diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 8f8eb65..b7ba208 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -37,6 +37,7 @@ import { createRawArtifact, createAreaScoreModelRun, createReport, + createReportGenerationRun, createScenarioRun, createWatchlistEvent, createWatchlistItem, @@ -53,6 +54,8 @@ import { fetchNeighborhoodScores, fetchRawArtifacts, fetchReport, + fetchReportGenerationRuns, + fetchReportTemplates, fetchReports, fetchScenarioRuns, fetchWatchlistEvents, @@ -71,6 +74,7 @@ import type { CreateIngestionRun, CreateRawArtifact, CreateReport, + CreateReportGenerationRun, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, @@ -83,6 +87,8 @@ import type { NeighborhoodScore, RawArtifact, Report, + ReportGenerationRun, + ReportTemplate, ScenarioRun, ScenarioRunResponse, SimilarNeighborhood, @@ -196,6 +202,14 @@ export function MarketDashboard() { queryFn: () => fetchReport(selectedReportId ?? 0), enabled: selectedReportId !== null, }); + const reportTemplates = useQuery({ + queryKey: ["report-templates"], + queryFn: fetchReportTemplates, + }); + const reportGenerationRuns = useQuery({ + queryKey: ["report-generation-runs", MONTH], + queryFn: () => fetchReportGenerationRuns({ target_month: MONTH }), + }); const dataSources = useQuery({ queryKey: ["data-sources"], queryFn: fetchDataSources, @@ -274,6 +288,21 @@ export function MarketDashboard() { ]); }, }); + const createReportGenerationMutation = useMutation({ + mutationFn: createReportGenerationRun, + onSuccess: async (response) => { + if (response.report) { + setSelectedReportId(response.report.report_id); + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reports"] }), + queryClient.invalidateQueries({ queryKey: ["report-generation-runs"] }), + response.report + ? queryClient.invalidateQueries({ queryKey: ["report", response.report.report_id] }) + : Promise.resolve(), + ]); + }, + }); const createDataSourceMutation = useMutation({ mutationFn: createDataSource, onSuccess: async () => { @@ -364,6 +393,8 @@ export function MarketDashboard() { scenarioRuns.isLoading || reports.isLoading || selectedReport.isLoading || + reportTemplates.isLoading || + reportGenerationRuns.isLoading || dataSources.isLoading || ingestionRuns.isLoading; const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading; @@ -382,6 +413,8 @@ export function MarketDashboard() { scenarioRuns.isError || reports.isError || selectedReport.isError || + reportTemplates.isError || + reportGenerationRuns.isError || dataSources.isError || ingestionRuns.isError || rawArtifacts.isError; @@ -417,6 +450,8 @@ export function MarketDashboard() { void scenarioRuns.refetch(); void reports.refetch(); void selectedReport.refetch(); + void reportTemplates.refetch(); + void reportGenerationRuns.refetch(); }} disabled={isLoading} aria-label="刷新" @@ -597,17 +632,26 @@ export function MarketDashboard() { areaScores={scores.data ?? []} neighborhoodScores={neighborhoodScores.data ?? []} reports={reports.data ?? []} + templates={reportTemplates.data ?? []} + generationRuns={reportGenerationRuns.data ?? []} selectedReport={selectedReport.data ?? null} selectedReportId={selectedReportId} reportTypeFilter={reportTypeFilter} - loading={reports.isLoading || selectedReport.isLoading} + loading={ + reports.isLoading || + selectedReport.isLoading || + reportTemplates.isLoading || + reportGenerationRuns.isLoading + } creating={createReportMutation.isPending} + generating={createReportGenerationMutation.isPending} onReportTypeFilterChange={(value) => { setReportTypeFilter(value); setSelectedReportId(null); }} onSelectReport={setSelectedReportId} onCreate={(payload) => createReportMutation.mutate(payload)} + onGenerate={(payload) => createReportGenerationMutation.mutate(payload)} /> void; onSelectReport: (reportId: number) => void; onCreate: (payload: CreateReport) => void; + onGenerate: (payload: CreateReportGenerationRun) => void; }) { const [draftType, setDraftType] = useState("monthly"); const [draftAreaId, setDraftAreaId] = useState(""); @@ -2629,7 +2681,7 @@ function ReportCenter({

- {(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => ( + {(["monthly", "weekly", "area_special", "neighborhood_memo"] as const).map((type) => ( +
+ {runs.length > 0 ? ( +
+ + + {runs.slice(0, 5).map((run) => ( + + + {run.template_name} + + {run.target_month} · {run.status} + + {run.error_message ? ( + {run.error_message} + ) : null} + + + {run.report_id ? ( + + ) : ( + + {run.status} + + )} + + + ))} + +
+
+ ) : null} + + ); +} + function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) { if (loading && !report) { return
; @@ -2854,6 +3023,9 @@ function reportTypeLabel(type: string) { if (type === "monthly") { return "月报"; } + if (type === "weekly") { + return "周报"; + } if (type === "area_special") { return "板块专题"; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ff866b6..8a84597 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -486,7 +486,7 @@ export type Report = { }; export type CreateReport = { - report_type: "monthly" | "area_special" | "neighborhood_memo"; + report_type: "monthly" | "weekly" | "area_special" | "neighborhood_memo"; title: string; target_month?: string; area_id?: string; @@ -505,6 +505,44 @@ export type ReportFilters = { status?: string; }; +export type ReportTemplate = { + template_id: number; + template_key: string; + name: string; + report_type: string; + cadence: string; + description: string; + body_template: string; + created_at: string; + updated_at: string; +}; + +export type ReportGenerationRun = { + generation_run_id: number; + template_id: number; + template_key: string; + template_name: string; + target_month: string; + status: string; + report_id: number | null; + report_title: string | null; + parameters: Record; + error_message: string | null; + started_at: string; + finished_at: string | null; +}; + +export type CreateReportGenerationRun = { + template_key: string; + target_month: string; + parameters?: Record; +}; + +export type ReportGenerationRunResponse = { + generation_run: ReportGenerationRun; + report: Report | null; +}; + export type ComparisonMetricValue = { id: string; value: number | null; @@ -798,6 +836,34 @@ export async function createReport(payload: CreateReport): Promise { }); } +export async function fetchReportTemplates(): Promise { + return fetchJson("/api/v1/report-templates"); +} + +export async function fetchReportGenerationRuns(filters: { + target_month?: string; + template_key?: string; + status?: string; +} = {}): Promise { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + if (value) { + params.set(key, value); + } + } + const query = params.toString(); + return fetchJson(`/api/v1/report-generation-runs${query ? `?${query}` : ""}`); +} + +export async function createReportGenerationRun( + payload: CreateReportGenerationRun, +): Promise { + return fetchJson("/api/v1/report-generation-runs", { + method: "POST", + body: JSON.stringify(payload), + }); +} + async function fetchJson(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 367bcaf..518b518 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -363,20 +363,26 @@ ### M4.2 自动周报/月报 +状态:已完成。 + 目标: - 定时生成市场周报和月报。 交付物: -- 报告任务。 -- 报告模板。 -- 报告生成记录。 +- `app.report_templates` 报告模板表。 +- `app.report_generation_runs` 报告生成记录表。 +- `GET /api/v1/report-templates`。 +- `GET /api/v1/report-generation-runs`。 +- `POST /api/v1/report-generation-runs` 手工触发市场月报和观察池周报生成。 +- 前端报告中心支持选择模板、触发生成、查看生成状态和产出报告。 验收标准: - 可手工触发。 - 可查看生成状态和错误信息。 +- 当前完成可手工触发的任务化生成,后续定时调度可复用同一 API。 ### M4.3 观察池预警