got a exporter for forecasting
This commit is contained in:
@@ -253,3 +253,5 @@ func (r *BudgetRepo) List(
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *BudgetRepo) DB() *sql.DB { return r.db }
|
||||||
|
|||||||
13
internal/database/forecast-repo.go
Normal file
13
internal/database/forecast-repo.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
149
internal/handler/forecast.go
Normal file
149
internal/handler/forecast.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type GLAccountType string
|
type GLAccountType string
|
||||||
|
|
||||||
@@ -361,3 +364,77 @@ type PnLRequest struct {
|
|||||||
DeptCodes []string
|
DeptCodes []string
|
||||||
Version BudgetVersion
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
11
internal/service/forecast-service.go
Normal file
11
internal/service/forecast-service.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
6
main.go
6
main.go
@@ -49,6 +49,8 @@ func main() {
|
|||||||
reportRepo := database.NewReportRepo(db)
|
reportRepo := database.NewReportRepo(db)
|
||||||
reportSvc := service.NewReportService(reportRepo)
|
reportSvc := service.NewReportService(reportRepo)
|
||||||
reportH := handler.NewReportHandler(reportSvc)
|
reportH := handler.NewReportHandler(reportSvc)
|
||||||
|
forecastH := handler.NewForecastHandler(budgetRepo, actualsRepo)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Reference endpoints
|
// Reference endpoints
|
||||||
@@ -75,6 +77,10 @@ func main() {
|
|||||||
mux.HandleFunc("GET /api/v1/variance/alerts", varianceH.Alerts)
|
mux.HandleFunc("GET /api/v1/variance/alerts", varianceH.Alerts)
|
||||||
mux.HandleFunc("GET /api/v1/variance/reforecast", 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
|
//reports
|
||||||
mux.HandleFunc("GET /api/v1/reports/pnl", reportH.PnL)
|
mux.HandleFunc("GET /api/v1/reports/pnl", reportH.PnL)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user