From 8a8a64c8afad41dd29d8b44693ab9ff216dc240a Mon Sep 17 00:00:00 2001 From: samantha42 Date: Sat, 21 Mar 2026 18:42:54 +0100 Subject: [PATCH] got a exporter for forecasting --- internal/database/budget-repo.go | 2 + internal/database/forecast-repo.go | 13 +++ internal/handler/forecast.go | 149 +++++++++++++++++++++++++++ internal/model/model.go | 79 +++++++++++++- internal/service/forecast-service.go | 11 ++ main.go | 6 ++ 6 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 internal/database/forecast-repo.go create mode 100644 internal/handler/forecast.go create mode 100644 internal/service/forecast-service.go diff --git a/internal/database/budget-repo.go b/internal/database/budget-repo.go index 2191118..4cc1741 100644 --- a/internal/database/budget-repo.go +++ b/internal/database/budget-repo.go @@ -253,3 +253,5 @@ func (r *BudgetRepo) List( } return out, rows.Err() } + +func (r *BudgetRepo) DB() *sql.DB { return r.db } diff --git a/internal/database/forecast-repo.go b/internal/database/forecast-repo.go new file mode 100644 index 0000000..cd29fde --- /dev/null +++ b/internal/database/forecast-repo.go @@ -0,0 +1,13 @@ +package database + +import "database/sql" + +type ForecastRepo struct { + db *sql.DB +} + +func NewForecastRepo(db *sql.DB) *ForecastRepo { + return &ForecastRepo{ + db: db, + } +} diff --git a/internal/handler/forecast.go b/internal/handler/forecast.go new file mode 100644 index 0000000..7e826e5 --- /dev/null +++ b/internal/handler/forecast.go @@ -0,0 +1,149 @@ +package handler + +import ( + "Engine/internal/database" + "Engine/internal/model" + "encoding/json" + "net/http" + "strconv" + "strings" +) + +type ForecastHandler struct { + budgets *database.BudgetRepo + actuals *database.ActualsRepo +} + +func NewForecastHandler(budgets *database.BudgetRepo, actuals *database.ActualsRepo) *ForecastHandler { + return &ForecastHandler{budgets: budgets, actuals: actuals} +} + +// GET /api/v1/forecast/export?dept=ENG&year=2024&version=original +func (h *ForecastHandler) Export(w http.ResponseWriter, r *http.Request) { + dept := r.URL.Query().Get("dept") + yearStr := r.URL.Query().Get("year") + version := model.BudgetVersion(r.URL.Query().Get("version")) + + if dept == "" { + writeError(w, http.StatusBadRequest, "dept is required") + return + } + year, err := strconv.Atoi(yearStr) + if err != nil || year < 1 { + writeError(w, http.StatusBadRequest, "year must be a valid integer") + return + } + if version == "" { + version = model.VersionOriginal + } + + // collect actuals for all 12 periods + var periodActuals []model.ForecastPeriodRow + for period := 1; period <= 12; period++ { + byGL, err := h.actuals.ListByPeriod(r.Context(), year, period, dept) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + for glCode, amount := range byGL { + periodActuals = append(periodActuals, model.ForecastPeriodRow{ + FiscalPeriod: period, + GLCode: glCode, + Amount: amount, + }) + } + } + + // collect budget for all 12 periods + var periodBudget []model.ForecastPeriodRow + for period := 1; period <= 12; period++ { + rows, err := h.budgets.List(r.Context(), year, period, dept, version) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + for _, b := range rows { + periodBudget = append(periodBudget, model.ForecastPeriodRow{ + FiscalPeriod: period, + GLCode: b.GLCode, + GLType: b.GLType, + Amount: b.Amount, + Currency: b.Currency, + }) + } + } + + // remaining periods = periods with no actuals yet + actualPeriods := map[int]bool{} + for _, a := range periodActuals { + actualPeriods[a.FiscalPeriod] = true + } + var remaining []int + for p := 1; p <= 12; p++ { + if !actualPeriods[p] { + remaining = append(remaining, p) + } + } + + writeJSON(w, http.StatusOK, model.ForecastExport{ + Department: dept, + FiscalYear: year, + Version: version, + PeriodsActual: periodActuals, + PeriodsBudget: periodBudget, + RemainingPeriods: remaining, + }) +} + +// POST /api/v1/forecast/ingest +func (h *ForecastHandler) Ingest(w http.ResponseWriter, r *http.Request) { + var req model.ForecastIngestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if errs := req.Valid(); len(errs) > 0 { + writeError(w, http.StatusBadRequest, strings.Join(errs, "; ")) + return + } + + // resolve dept ID once — all lines share the same department + var deptID int + if err := h.budgets.DB().QueryRowContext(r.Context(), + `SELECT id FROM departments WHERE code = ?`, req.Department, + ).Scan(&deptID); err != nil { + writeError(w, http.StatusBadRequest, "department not found: "+req.Department) + return + } + + var created []*model.Budget + for _, line := range req.Lines { + // resolve GL account ID per line + var glID int + if err := h.budgets.DB().QueryRowContext(r.Context(), + `SELECT id FROM gl_accounts WHERE code = ?`, line.GLCode, + ).Scan(&glID); err != nil { + writeError(w, http.StatusBadRequest, "gl_account not found: "+line.GLCode) + return + } + + b, err := h.budgets.Create(r.Context(), model.CreateBudgetRequest{ + FiscalYear: req.FiscalYear, + FiscalPeriod: line.FiscalPeriod, + Version: req.Version, + DepartmentID: deptID, + GLAccountID: glID, + Amount: line.Amount, + Currency: line.Currency, + CreatedBy: req.CreatedBy, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + created = append(created, b) + } + + writeJSON(w, http.StatusCreated, created) +} diff --git a/internal/model/model.go b/internal/model/model.go index e408ab9..7a360f2 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "fmt" + "time" +) type GLAccountType string @@ -361,3 +364,77 @@ type PnLRequest struct { DeptCodes []string Version BudgetVersion } + +// ForecastIngestRequest is the payload an external model POSTs back +// after consuming the export endpoint. +type ForecastIngestRequest struct { + Department string `json:"department"` + FiscalYear int `json:"fiscal_year"` + Version BudgetVersion `json:"version"` + CreatedBy string `json:"created_by"` + Lines []ForecastLine `json:"lines"` +} + +type ForecastLine struct { + FiscalPeriod int `json:"period"` + GLCode string `json:"gl_code"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +func (f *ForecastIngestRequest) Valid() []string { + var errs []string + + if f.Department == "" { + errs = append(errs, "department is required") + } + if f.FiscalYear < 1 || f.FiscalYear > 2200 { + errs = append(errs, "fiscal_year must be between 1 and 2200") + } + if f.Version == "" { + errs = append(errs, "version is required") + } + if f.Version == VersionOriginal { + errs = append(errs, "version cannot be original — forecasts must use forecast_1, forecast_2, or forecast_3") + } + if f.CreatedBy == "" { + errs = append(errs, "created_by is required") + } + if len(f.Lines) == 0 { + errs = append(errs, "lines must contain at least one record") + } + + for i, line := range f.Lines { + if line.FiscalPeriod < 1 || line.FiscalPeriod > 12 { + errs = append(errs, fmt.Sprintf("lines[%d]: period must be between 1 and 12", i)) + } + if line.GLCode == "" { + errs = append(errs, fmt.Sprintf("lines[%d]: gl_code is required", i)) + } + if line.Amount <= 0 { + errs = append(errs, fmt.Sprintf("lines[%d]: amount must be greater than 0", i)) + } + if line.Currency == "" { + errs = append(errs, fmt.Sprintf("lines[%d]: currency is required", i)) + } + } + + return errs +} + +type ForecastPeriodRow struct { + FiscalPeriod int `json:"period"` + GLCode string `json:"gl_code"` + GLType GLAccountType `json:"gl_type,omitempty"` + Amount float64 `json:"amount"` + Currency string `json:"currency,omitempty"` +} + +type ForecastExport struct { + Department string `json:"department"` + FiscalYear int `json:"fiscal_year"` + Version BudgetVersion `json:"version"` + PeriodsActual []ForecastPeriodRow `json:"periods_actual"` + PeriodsBudget []ForecastPeriodRow `json:"periods_budget"` + RemainingPeriods []int `json:"remaining_periods"` +} diff --git a/internal/service/forecast-service.go b/internal/service/forecast-service.go new file mode 100644 index 0000000..c6ac9aa --- /dev/null +++ b/internal/service/forecast-service.go @@ -0,0 +1,11 @@ +package service + +import "Engine/internal/database" + +type ForecastService struct { + repo *database.ForecastRepo +} + +func NewForecastService(repo *database.ForecastRepo) *ForecastService { + return &ForecastService{repo: repo} +} diff --git a/main.go b/main.go index 5ad4fb2..27a4bc5 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,8 @@ func main() { reportRepo := database.NewReportRepo(db) reportSvc := service.NewReportService(reportRepo) reportH := handler.NewReportHandler(reportSvc) + forecastH := handler.NewForecastHandler(budgetRepo, actualsRepo) + mux := http.NewServeMux() // Reference endpoints @@ -75,6 +77,10 @@ func main() { mux.HandleFunc("GET /api/v1/variance/alerts", varianceH.Alerts) mux.HandleFunc("GET /api/v1/variance/reforecast", varianceH.Alerts) + //forecast + mux.HandleFunc("GET /api/v1/forecast/export", forecastH.Export) + mux.HandleFunc("POST /api/v1/forecast/ingest", forecastH.Ingest) + //reports mux.HandleFunc("GET /api/v1/reports/pnl", reportH.PnL)