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
- {(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => (
+ {(["monthly", "weekly", "area_special", "neighborhood_memo"] as const).map((type) => (