got a exporter for forecasting
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user