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,
|
AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
|
||||||
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
|
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
|
||||||
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
||||||
CreateRawArtifact, CreateReport, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem,
|
CreateRawArtifact, CreateReport, CreateReportGenerationRun, CreateScenarioRun,
|
||||||
DataSource, DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest,
|
CreateWatchlistEvent, CreateWatchlistItem, DataSource, DiagnosticMetric, FinishIngestionRun,
|
||||||
ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun,
|
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||||
IngestionRunQuery, MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery,
|
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||||
Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail,
|
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
|
||||||
NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
||||||
RawArtifact, RawArtifactQuery, Report, ReportQuery, ScenarioAreaResult, ScenarioRun,
|
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, Report,
|
||||||
ScenarioRunQuery, ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood,
|
ReportGenerationRun, ReportGenerationRunQuery, ReportGenerationRunResponse, ReportQuery,
|
||||||
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse,
|
ReportTemplate, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery, ScenarioRunResponse,
|
||||||
UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery,
|
ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery,
|
||||||
|
SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent, WatchlistItem,
|
||||||
|
WatchlistQuery,
|
||||||
};
|
};
|
||||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -1907,42 +1909,162 @@ pub async fn create_report(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut tx = state.pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
let report_id = sqlx::query_scalar::<_, i64>(
|
let report = insert_report_from_payload(
|
||||||
r#"
|
&mut tx,
|
||||||
INSERT INTO app.reports (
|
CreateReport {
|
||||||
report_type,
|
linked_metrics: Some(linked_metrics),
|
||||||
title,
|
..payload
|
||||||
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(linked_metrics)
|
|
||||||
.bind(payload.status.clone())
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
.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?;
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(Json(report))
|
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(
|
async fn insert_watchlist_event(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
watchlist_item_id: i64,
|
watchlist_item_id: i64,
|
||||||
@@ -2003,6 +2125,406 @@ async fn fetch_report_by_id(
|
|||||||
.await
|
.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<()> {
|
fn validate_report_query(query: &ReportQuery) -> ApiResult<()> {
|
||||||
if let Some(report_type) = query.report_type.as_deref() {
|
if let Some(report_type) = query.report_type.as_deref() {
|
||||||
validate_report_type(report_type)?;
|
validate_report_type(report_type)?;
|
||||||
@@ -2042,6 +2564,19 @@ fn validate_create_report(payload: &CreateReport) -> ApiResult<()> {
|
|||||||
Ok(())
|
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> {
|
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, AreaScore>(
|
sqlx::query_as::<_, AreaScore>(
|
||||||
r#"
|
r#"
|
||||||
@@ -3032,11 +3567,14 @@ fn validate_identifier(value: &str) -> ApiResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_report_type(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(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest(
|
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 {
|
fn has_sensitive_text(value: &str) -> bool {
|
||||||
let lower = value.to_ascii_lowercase();
|
let lower = value.to_ascii_lowercase();
|
||||||
lower.contains("password")
|
lower.contains("password")
|
||||||
@@ -3065,9 +3613,9 @@ fn has_sensitive_text(value: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport, CreateScenarioRun,
|
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport,
|
||||||
CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate,
|
CreateReportGenerationRun, CreateScenarioRun, CreateWatchlistItem, FinishIngestionRun,
|
||||||
UpdateWatchlistItem,
|
ScenarioRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -3311,6 +3859,33 @@ mod tests {
|
|||||||
.is_err());
|
.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]
|
#[test]
|
||||||
fn validates_scenario_run_payload() {
|
fn validates_scenario_run_payload() {
|
||||||
assert!(CreateScenarioRun {
|
assert!(CreateScenarioRun {
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ pub struct ReportQuery {
|
|||||||
pub status: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RawArtifactQuery {
|
pub struct RawArtifactQuery {
|
||||||
pub source_id: Option<i64>,
|
pub source_id: Option<i64>,
|
||||||
@@ -627,10 +634,69 @@ pub struct CreateReport {
|
|||||||
pub status: Option<String>,
|
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 {
|
impl CreateReport {
|
||||||
pub fn validate(&self) -> Result<(), &'static str> {
|
pub fn validate(&self) -> Result<(), &'static str> {
|
||||||
if !is_report_type(&self.report_type) {
|
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() {
|
if self.title.trim().is_empty() {
|
||||||
return Err("title is required");
|
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 {
|
fn is_report_type(report_type: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
report_type,
|
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::{
|
use crate::handlers::{
|
||||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
||||||
create_data_source, create_ingestion_run, create_raw_artifact, create_report,
|
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,
|
create_report_generation_run, create_scenario_run, create_watchlist_event,
|
||||||
execute_import, finish_ingestion_run, get_area_detail, get_area_diagnostics,
|
create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
|
||||||
get_area_score_lineage, get_neighborhood, get_neighborhood_detail, get_report,
|
get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
|
||||||
get_similar_neighborhoods, health, list_area_scores, list_data_sources, list_ingestion_runs,
|
get_neighborhood_detail, get_report, get_similar_neighborhoods, health, list_area_scores,
|
||||||
list_model_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
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,
|
list_reports, list_scenario_runs, list_watchlist_events, list_watchlist_items, market_overview,
|
||||||
ready, update_watchlist_item, validate_import,
|
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),
|
get(list_scenario_runs).post(create_scenario_run),
|
||||||
)
|
)
|
||||||
.route("/reports", get(list_reports).post(create_report))
|
.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(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
createRawArtifact,
|
createRawArtifact,
|
||||||
createAreaScoreModelRun,
|
createAreaScoreModelRun,
|
||||||
createReport,
|
createReport,
|
||||||
|
createReportGenerationRun,
|
||||||
createScenarioRun,
|
createScenarioRun,
|
||||||
createWatchlistEvent,
|
createWatchlistEvent,
|
||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
@@ -53,6 +54,8 @@ import {
|
|||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
fetchRawArtifacts,
|
fetchRawArtifacts,
|
||||||
fetchReport,
|
fetchReport,
|
||||||
|
fetchReportGenerationRuns,
|
||||||
|
fetchReportTemplates,
|
||||||
fetchReports,
|
fetchReports,
|
||||||
fetchScenarioRuns,
|
fetchScenarioRuns,
|
||||||
fetchWatchlistEvents,
|
fetchWatchlistEvents,
|
||||||
@@ -71,6 +74,7 @@ import type {
|
|||||||
CreateIngestionRun,
|
CreateIngestionRun,
|
||||||
CreateRawArtifact,
|
CreateRawArtifact,
|
||||||
CreateReport,
|
CreateReport,
|
||||||
|
CreateReportGenerationRun,
|
||||||
CreateScenarioRun,
|
CreateScenarioRun,
|
||||||
CreateWatchlistEvent,
|
CreateWatchlistEvent,
|
||||||
CreateWatchlistItem,
|
CreateWatchlistItem,
|
||||||
@@ -83,6 +87,8 @@ import type {
|
|||||||
NeighborhoodScore,
|
NeighborhoodScore,
|
||||||
RawArtifact,
|
RawArtifact,
|
||||||
Report,
|
Report,
|
||||||
|
ReportGenerationRun,
|
||||||
|
ReportTemplate,
|
||||||
ScenarioRun,
|
ScenarioRun,
|
||||||
ScenarioRunResponse,
|
ScenarioRunResponse,
|
||||||
SimilarNeighborhood,
|
SimilarNeighborhood,
|
||||||
@@ -196,6 +202,14 @@ export function MarketDashboard() {
|
|||||||
queryFn: () => fetchReport(selectedReportId ?? 0),
|
queryFn: () => fetchReport(selectedReportId ?? 0),
|
||||||
enabled: selectedReportId !== null,
|
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({
|
const dataSources = useQuery({
|
||||||
queryKey: ["data-sources"],
|
queryKey: ["data-sources"],
|
||||||
queryFn: fetchDataSources,
|
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({
|
const createDataSourceMutation = useMutation({
|
||||||
mutationFn: createDataSource,
|
mutationFn: createDataSource,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -364,6 +393,8 @@ export function MarketDashboard() {
|
|||||||
scenarioRuns.isLoading ||
|
scenarioRuns.isLoading ||
|
||||||
reports.isLoading ||
|
reports.isLoading ||
|
||||||
selectedReport.isLoading ||
|
selectedReport.isLoading ||
|
||||||
|
reportTemplates.isLoading ||
|
||||||
|
reportGenerationRuns.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
ingestionRuns.isLoading;
|
ingestionRuns.isLoading;
|
||||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||||
@@ -382,6 +413,8 @@ export function MarketDashboard() {
|
|||||||
scenarioRuns.isError ||
|
scenarioRuns.isError ||
|
||||||
reports.isError ||
|
reports.isError ||
|
||||||
selectedReport.isError ||
|
selectedReport.isError ||
|
||||||
|
reportTemplates.isError ||
|
||||||
|
reportGenerationRuns.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
ingestionRuns.isError ||
|
ingestionRuns.isError ||
|
||||||
rawArtifacts.isError;
|
rawArtifacts.isError;
|
||||||
@@ -417,6 +450,8 @@ export function MarketDashboard() {
|
|||||||
void scenarioRuns.refetch();
|
void scenarioRuns.refetch();
|
||||||
void reports.refetch();
|
void reports.refetch();
|
||||||
void selectedReport.refetch();
|
void selectedReport.refetch();
|
||||||
|
void reportTemplates.refetch();
|
||||||
|
void reportGenerationRuns.refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
@@ -597,17 +632,26 @@ export function MarketDashboard() {
|
|||||||
areaScores={scores.data ?? []}
|
areaScores={scores.data ?? []}
|
||||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||||
reports={reports.data ?? []}
|
reports={reports.data ?? []}
|
||||||
|
templates={reportTemplates.data ?? []}
|
||||||
|
generationRuns={reportGenerationRuns.data ?? []}
|
||||||
selectedReport={selectedReport.data ?? null}
|
selectedReport={selectedReport.data ?? null}
|
||||||
selectedReportId={selectedReportId}
|
selectedReportId={selectedReportId}
|
||||||
reportTypeFilter={reportTypeFilter}
|
reportTypeFilter={reportTypeFilter}
|
||||||
loading={reports.isLoading || selectedReport.isLoading}
|
loading={
|
||||||
|
reports.isLoading ||
|
||||||
|
selectedReport.isLoading ||
|
||||||
|
reportTemplates.isLoading ||
|
||||||
|
reportGenerationRuns.isLoading
|
||||||
|
}
|
||||||
creating={createReportMutation.isPending}
|
creating={createReportMutation.isPending}
|
||||||
|
generating={createReportGenerationMutation.isPending}
|
||||||
onReportTypeFilterChange={(value) => {
|
onReportTypeFilterChange={(value) => {
|
||||||
setReportTypeFilter(value);
|
setReportTypeFilter(value);
|
||||||
setSelectedReportId(null);
|
setSelectedReportId(null);
|
||||||
}}
|
}}
|
||||||
onSelectReport={setSelectedReportId}
|
onSelectReport={setSelectedReportId}
|
||||||
onCreate={(payload) => createReportMutation.mutate(payload)}
|
onCreate={(payload) => createReportMutation.mutate(payload)}
|
||||||
|
onGenerate={(payload) => createReportGenerationMutation.mutate(payload)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelRunPanel
|
<ModelRunPanel
|
||||||
@@ -2561,26 +2605,34 @@ function ReportCenter({
|
|||||||
areaScores,
|
areaScores,
|
||||||
neighborhoodScores,
|
neighborhoodScores,
|
||||||
reports,
|
reports,
|
||||||
|
templates,
|
||||||
|
generationRuns,
|
||||||
selectedReport,
|
selectedReport,
|
||||||
selectedReportId,
|
selectedReportId,
|
||||||
reportTypeFilter,
|
reportTypeFilter,
|
||||||
loading,
|
loading,
|
||||||
creating,
|
creating,
|
||||||
|
generating,
|
||||||
onReportTypeFilterChange,
|
onReportTypeFilterChange,
|
||||||
onSelectReport,
|
onSelectReport,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onGenerate,
|
||||||
}: {
|
}: {
|
||||||
areaScores: AreaScore[];
|
areaScores: AreaScore[];
|
||||||
neighborhoodScores: NeighborhoodScore[];
|
neighborhoodScores: NeighborhoodScore[];
|
||||||
reports: Report[];
|
reports: Report[];
|
||||||
|
templates: ReportTemplate[];
|
||||||
|
generationRuns: ReportGenerationRun[];
|
||||||
selectedReport: Report | null;
|
selectedReport: Report | null;
|
||||||
selectedReportId: number | null;
|
selectedReportId: number | null;
|
||||||
reportTypeFilter: string;
|
reportTypeFilter: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
|
generating: boolean;
|
||||||
onReportTypeFilterChange: (value: string) => void;
|
onReportTypeFilterChange: (value: string) => void;
|
||||||
onSelectReport: (reportId: number) => void;
|
onSelectReport: (reportId: number) => void;
|
||||||
onCreate: (payload: CreateReport) => void;
|
onCreate: (payload: CreateReport) => void;
|
||||||
|
onGenerate: (payload: CreateReportGenerationRun) => void;
|
||||||
}) {
|
}) {
|
||||||
const [draftType, setDraftType] = useState<CreateReport["report_type"]>("monthly");
|
const [draftType, setDraftType] = useState<CreateReport["report_type"]>("monthly");
|
||||||
const [draftAreaId, setDraftAreaId] = useState("");
|
const [draftAreaId, setDraftAreaId] = useState("");
|
||||||
@@ -2629,7 +2681,7 @@ function ReportCenter({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => (
|
{(["monthly", "weekly", "area_special", "neighborhood_memo"] as const).map((type) => (
|
||||||
<Button
|
<Button
|
||||||
key={type}
|
key={type}
|
||||||
variant={reportTypeFilter === type ? "default" : "outline"}
|
variant={reportTypeFilter === type ? "default" : "outline"}
|
||||||
@@ -2642,6 +2694,14 @@ function ReportCenter({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
<ReportGenerationPanel
|
||||||
|
templates={templates}
|
||||||
|
runs={generationRuns}
|
||||||
|
loading={loading}
|
||||||
|
generating={generating}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
onSelectReport={onSelectReport}
|
||||||
|
/>
|
||||||
<div className="rounded-md border border-[var(--border)] p-4">
|
<div className="rounded-md border border-[var(--border)] p-4">
|
||||||
<p className="text-sm font-semibold text-slate-950">新建报告</p>
|
<p className="text-sm font-semibold text-slate-950">新建报告</p>
|
||||||
<div className="mt-3 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
@@ -2789,6 +2849,115 @@ function ReportList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReportGenerationPanel({
|
||||||
|
templates,
|
||||||
|
runs,
|
||||||
|
loading,
|
||||||
|
generating,
|
||||||
|
onGenerate,
|
||||||
|
onSelectReport,
|
||||||
|
}: {
|
||||||
|
templates: ReportTemplate[];
|
||||||
|
runs: ReportGenerationRun[];
|
||||||
|
loading: boolean;
|
||||||
|
generating: boolean;
|
||||||
|
onGenerate: (payload: CreateReportGenerationRun) => void;
|
||||||
|
onSelectReport: (reportId: number) => void;
|
||||||
|
}) {
|
||||||
|
const [templateKey, setTemplateKey] = useState("");
|
||||||
|
const selectedTemplate = templates.find((template) => template.template_key === templateKey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!templateKey && templates.length > 0) {
|
||||||
|
setTemplateKey(templates[0].template_key);
|
||||||
|
}
|
||||||
|
}, [templateKey, templates]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-[var(--border)] p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-950">自动生成</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
手工触发周报/月报生成任务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">{runs.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<label className="grid gap-1 text-sm">
|
||||||
|
<span className="font-medium text-slate-950">模板</span>
|
||||||
|
<select
|
||||||
|
value={templateKey}
|
||||||
|
onChange={(event) => setTemplateKey(event.target.value)}
|
||||||
|
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||||
|
disabled={templates.length === 0}
|
||||||
|
>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.template_key} value={template.template_key}>
|
||||||
|
{template.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectedTemplate ? (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{selectedTemplate.cadence} · {selectedTemplate.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
onGenerate({
|
||||||
|
template_key: templateKey,
|
||||||
|
target_month: MONTH,
|
||||||
|
parameters: { source: "dashboard" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={generating || loading || !templateKey}
|
||||||
|
>
|
||||||
|
{generating ? "生成中" : "生成报告"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{runs.length > 0 ? (
|
||||||
|
<div className="mt-4 overflow-x-auto rounded-md border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{runs.slice(0, 5).map((run) => (
|
||||||
|
<TableRow key={run.generation_run_id}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="block font-medium text-slate-950">{run.template_name}</span>
|
||||||
|
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||||
|
{run.target_month} · {run.status}
|
||||||
|
</span>
|
||||||
|
{run.error_message ? (
|
||||||
|
<span className="block text-xs text-rose-700">{run.error_message}</span>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{run.report_id ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 px-2"
|
||||||
|
onClick={() => onSelectReport(run.report_id ?? 0)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Badge variant={run.status === "failed" ? "danger" : "muted"}>
|
||||||
|
{run.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) {
|
function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) {
|
||||||
if (loading && !report) {
|
if (loading && !report) {
|
||||||
return <div className="h-[520px] rounded-md bg-[var(--muted)]" />;
|
return <div className="h-[520px] rounded-md bg-[var(--muted)]" />;
|
||||||
@@ -2854,6 +3023,9 @@ function reportTypeLabel(type: string) {
|
|||||||
if (type === "monthly") {
|
if (type === "monthly") {
|
||||||
return "月报";
|
return "月报";
|
||||||
}
|
}
|
||||||
|
if (type === "weekly") {
|
||||||
|
return "周报";
|
||||||
|
}
|
||||||
if (type === "area_special") {
|
if (type === "area_special") {
|
||||||
return "板块专题";
|
return "板块专题";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ export type Report = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CreateReport = {
|
export type CreateReport = {
|
||||||
report_type: "monthly" | "area_special" | "neighborhood_memo";
|
report_type: "monthly" | "weekly" | "area_special" | "neighborhood_memo";
|
||||||
title: string;
|
title: string;
|
||||||
target_month?: string;
|
target_month?: string;
|
||||||
area_id?: string;
|
area_id?: string;
|
||||||
@@ -505,6 +505,44 @@ export type ReportFilters = {
|
|||||||
status?: string;
|
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<string, unknown>;
|
||||||
|
error_message: string | null;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateReportGenerationRun = {
|
||||||
|
template_key: string;
|
||||||
|
target_month: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportGenerationRunResponse = {
|
||||||
|
generation_run: ReportGenerationRun;
|
||||||
|
report: Report | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ComparisonMetricValue = {
|
export type ComparisonMetricValue = {
|
||||||
id: string;
|
id: string;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
@@ -798,6 +836,34 @@ export async function createReport(payload: CreateReport): Promise<Report> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchReportTemplates(): Promise<ReportTemplate[]> {
|
||||||
|
return fetchJson("/api/v1/report-templates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReportGenerationRuns(filters: {
|
||||||
|
target_month?: string;
|
||||||
|
template_key?: string;
|
||||||
|
status?: string;
|
||||||
|
} = {}): Promise<ReportGenerationRun[]> {
|
||||||
|
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<ReportGenerationRunResponse> {
|
||||||
|
return fetchJson("/api/v1/report-generation-runs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -363,20 +363,26 @@
|
|||||||
|
|
||||||
### M4.2 自动周报/月报
|
### 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 观察池预警
|
### M4.3 观察池预警
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user