Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
237
backend/api/export.py
Normal file
237
backend/api/export.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""
|
||||
Export API endpoints for Excel/CSV downloads
|
||||
"""
|
||||
import io
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
import pandas as pd
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/excel")
|
||||
async def export_excel(
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Download Excel workbook with multiple sheets:
|
||||
- Daily Forecast (all models)
|
||||
- Weekly Summary
|
||||
- Budget Comparison
|
||||
- Model Accuracy
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today()
|
||||
if to_date is None:
|
||||
to_date = from_date + timedelta(days=28)
|
||||
|
||||
# Create Excel writer
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
# Daily forecasts sheet
|
||||
daily_query = """
|
||||
SELECT
|
||||
f.forecast_date as "Date",
|
||||
f.forecast_type as "Metric",
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as "Prophet",
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.lower_bound END) as "Prophet Lower",
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.upper_bound END) as "Prophet Upper",
|
||||
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as "XGBoost",
|
||||
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as "Pickup",
|
||||
db.budget_value as "Budget"
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
GROUP BY f.forecast_date, f.forecast_type, db.budget_value
|
||||
ORDER BY f.forecast_date, f.forecast_type
|
||||
"""
|
||||
result = await db.execute(text(daily_query), {"from_date": from_date, "to_date": to_date})
|
||||
daily_df = pd.DataFrame(result.fetchall())
|
||||
if not daily_df.empty:
|
||||
daily_df.to_excel(writer, sheet_name='Daily Forecast', index=False)
|
||||
|
||||
# Weekly summary sheet
|
||||
weekly_query = """
|
||||
SELECT
|
||||
DATE_TRUNC('week', f.forecast_date) as "Week Start",
|
||||
f.forecast_type as "Metric",
|
||||
AVG(f.predicted_value) as "Avg Forecast",
|
||||
SUM(f.predicted_value) as "Total Forecast",
|
||||
AVG(db.budget_value) as "Avg Budget",
|
||||
SUM(db.budget_value) as "Total Budget"
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
AND f.model_type = 'prophet'
|
||||
GROUP BY DATE_TRUNC('week', f.forecast_date), f.forecast_type
|
||||
ORDER BY "Week Start", f.forecast_type
|
||||
"""
|
||||
result = await db.execute(text(weekly_query), {"from_date": from_date, "to_date": to_date})
|
||||
weekly_df = pd.DataFrame(result.fetchall())
|
||||
if not weekly_df.empty:
|
||||
weekly_df.to_excel(writer, sheet_name='Weekly Summary', index=False)
|
||||
|
||||
# Budget variance sheet
|
||||
variance_query = """
|
||||
SELECT
|
||||
f.forecast_date as "Date",
|
||||
f.forecast_type as "Metric",
|
||||
f.predicted_value as "Forecast",
|
||||
db.budget_value as "Budget",
|
||||
(f.predicted_value - db.budget_value) as "Variance",
|
||||
CASE
|
||||
WHEN db.budget_value != 0 THEN
|
||||
ROUND(((f.predicted_value - db.budget_value) / db.budget_value * 100)::numeric, 1)
|
||||
ELSE NULL
|
||||
END as "Variance %"
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
AND f.model_type = 'prophet'
|
||||
ORDER BY f.forecast_date, f.forecast_type
|
||||
"""
|
||||
result = await db.execute(text(variance_query), {"from_date": from_date, "to_date": to_date})
|
||||
variance_df = pd.DataFrame(result.fetchall())
|
||||
if not variance_df.empty:
|
||||
variance_df.to_excel(writer, sheet_name='Budget Variance', index=False)
|
||||
|
||||
# Accuracy sheet (historical)
|
||||
accuracy_query = """
|
||||
SELECT
|
||||
date as "Date",
|
||||
metric_type as "Metric",
|
||||
actual_value as "Actual",
|
||||
prophet_forecast as "Prophet",
|
||||
xgboost_forecast as "XGBoost",
|
||||
pickup_forecast as "Pickup",
|
||||
best_model as "Best Model"
|
||||
FROM actual_vs_forecast
|
||||
WHERE date BETWEEN :from_date - INTERVAL '30 days' AND :from_date
|
||||
ORDER BY date, metric_type
|
||||
"""
|
||||
result = await db.execute(text(accuracy_query), {"from_date": from_date, "to_date": to_date})
|
||||
accuracy_df = pd.DataFrame(result.fetchall())
|
||||
if not accuracy_df.empty:
|
||||
accuracy_df.to_excel(writer, sheet_name='Historical Accuracy', index=False)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
filename = f"forecast_{from_date}_{to_date}.xlsx"
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/csv/{metric}")
|
||||
async def export_csv(
|
||||
metric: str,
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Download CSV for a specific metric.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today()
|
||||
if to_date is None:
|
||||
to_date = from_date + timedelta(days=28)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
f.forecast_date,
|
||||
f.model_type,
|
||||
f.predicted_value,
|
||||
f.lower_bound,
|
||||
f.upper_bound,
|
||||
dm.actual_value,
|
||||
db.budget_value
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
AND f.forecast_type = :metric
|
||||
ORDER BY f.forecast_date, f.model_type
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"metric": metric
|
||||
})
|
||||
|
||||
df = pd.DataFrame(result.fetchall())
|
||||
|
||||
output = io.StringIO()
|
||||
df.to_csv(output, index=False)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"{metric}_{from_date}_{to_date}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/model-comparison")
|
||||
async def export_model_comparison(
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Export model comparison data for all metrics.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today()
|
||||
if to_date is None:
|
||||
to_date = from_date + timedelta(days=28)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
f.forecast_date,
|
||||
f.forecast_type,
|
||||
fm.metric_name,
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as prophet,
|
||||
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as xgboost,
|
||||
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as pickup,
|
||||
dm.actual_value
|
||||
FROM forecasts f
|
||||
LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.metric_code
|
||||
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
GROUP BY f.forecast_date, f.forecast_type, fm.metric_name, dm.actual_value
|
||||
ORDER BY f.forecast_date, f.forecast_type
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
df = pd.DataFrame(result.fetchall())
|
||||
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, sheet_name='Model Comparison', index=False)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
filename = f"model_comparison_{from_date}_{to_date}.xlsx"
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue