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) }