basic online

This commit is contained in:
zipfriis
2026-03-20 14:01:46 +01:00
parent 5083212e07
commit cd2db504d1
16 changed files with 1137 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
package database
import (
"context"
"fmt"
"Engine/internal/model"
)
type ActualsRepo struct {
db *DB
}
func NewActualsRepo(db *DB) *ActualsRepo {
return &ActualsRepo{db: db}
}
func (r *ActualsRepo) Ingest(ctx context.Context, req model.IngestActualsRequest) (*model.Actual, error) {
var deptID, glID int
if err := r.db.QueryRowContext(ctx, `SELECT id FROM departments WHERE code=?`, req.DeptCode).Scan(&deptID); err != nil {
return nil, fmt.Errorf("department %q not found: %w", req.DeptCode, err)
}
if err := r.db.QueryRowContext(ctx, `SELECT id FROM gl_accounts WHERE code=?`, req.GLCode).Scan(&glID); err != nil {
return nil, fmt.Errorf("GL account %q not found: %w", req.GLCode, err)
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO actuals (fiscal_year, fiscal_period, department_id, gl_account_id, amount, currency, source)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT (fiscal_year, fiscal_period, department_id, gl_account_id)
DO UPDATE SET amount=excluded.amount, source=excluded.source`,
req.FiscalYear, req.FiscalPeriod, deptID, glID,
req.Amount, req.Currency, req.Source,
)
if err != nil {
return nil, fmt.Errorf("upsert actual: %w", err)
}
a := &model.Actual{}
err = r.db.QueryRowContext(ctx, `
SELECT id, fiscal_year, fiscal_period, department_id, gl_account_id,
amount, currency, source, ingested_at
FROM actuals
WHERE fiscal_year=? AND fiscal_period=? AND department_id=? AND gl_account_id=?`,
req.FiscalYear, req.FiscalPeriod, deptID, glID,
).Scan(
&a.ID, &a.FiscalYear, &a.FiscalPeriod,
&a.DepartmentID, &a.GLAccountID,
&a.Amount, &a.Currency, &a.Source, &a.IngestedAt,
)
if err != nil {
return nil, fmt.Errorf("fetch ingested actual: %w", err)
}
return a, nil
}
func (r *ActualsRepo) ListByPeriod(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) (map[string]float64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT g.code, a.amount
FROM actuals a
JOIN departments d ON d.id = a.department_id
JOIN gl_accounts g ON g.id = a.gl_account_id
WHERE a.fiscal_year = ?
AND a.fiscal_period = ?
AND (? = '' OR d.code = ?)`,
fiscalYear, fiscalPeriod, deptCode, deptCode,
)
if err != nil {
return nil, fmt.Errorf("list actuals: %w", err)
}
defer rows.Close()
result := make(map[string]float64)
for rows.Next() {
var code string
var amount float64
if err := rows.Scan(&code, &amount); err != nil {
return nil, err
}
result[code] += amount
}
return result, rows.Err()
}

View File

@@ -0,0 +1,116 @@
package database
import (
"context"
"fmt"
"Engine/internal/model"
)
type BudgetRepo struct {
db *DB
}
func NewBudgetRepo(db *DB) *BudgetRepo {
return &BudgetRepo{db: db}
}
func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest) (*model.Budget, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO budgets
(fiscal_year, fiscal_period, version, department_id, gl_account_id, amount, currency, notes, created_by)
VALUES (?,?,?,?,?,?,?,?,?)`,
req.FiscalYear, req.FiscalPeriod, req.Version,
req.DepartmentID, req.GLAccountID, req.Amount,
req.Currency, req.Notes, req.CreatedBy,
)
if err != nil {
return nil, fmt.Errorf("create budget: %w", err)
}
id, _ := res.LastInsertId()
b := &model.Budget{}
err = r.db.QueryRowContext(ctx, `
SELECT id, fiscal_year, fiscal_period, version, department_id, gl_account_id,
amount, currency, notes, created_by, created_at, updated_at
FROM budgets WHERE id = ?`, id,
).Scan(
&b.ID, &b.FiscalYear, &b.FiscalPeriod, &b.Version,
&b.DepartmentID, &b.GLAccountID, &b.Amount, &b.Currency,
&b.Notes, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("fetch created budget: %w", err)
}
return b, nil
}
func (r *BudgetRepo) List(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.BudgetRow, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT g.code, g.description, g.type, g.favour_high, b.amount, b.currency
FROM budgets b
JOIN departments d ON d.id = b.department_id
JOIN gl_accounts g ON g.id = b.gl_account_id
WHERE b.fiscal_year = ?
AND b.fiscal_period = ?
AND (? = '' OR d.code = ?)
AND (? = '' OR b.version = ?)
ORDER BY g.code`,
fiscalYear, fiscalPeriod,
deptCode, deptCode,
string(version), string(version),
)
if err != nil {
return nil, fmt.Errorf("list budgets: %w", err)
}
defer rows.Close()
var result []model.BudgetRow
for rows.Next() {
var row model.BudgetRow
if err := rows.Scan(&row.GLCode, &row.GLDescription, &row.GLType, &row.FavourHigh, &row.Amount, &row.Currency); err != nil {
return nil, err
}
result = append(result, row)
}
return result, rows.Err()
}
func (r *BudgetRepo) Update(ctx context.Context, id int, amount float64, notes, _ string) (*model.Budget, error) {
_, err := r.db.ExecContext(ctx, `
UPDATE budgets
SET amount=?, notes=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
amount, notes, id,
)
if err != nil {
return nil, fmt.Errorf("update budget %d: %w", id, err)
}
b := &model.Budget{}
err = r.db.QueryRowContext(ctx, `
SELECT id, fiscal_year, fiscal_period, version, department_id, gl_account_id,
amount, currency, notes, created_by, created_at, updated_at
FROM budgets WHERE id = ?`, id,
).Scan(
&b.ID, &b.FiscalYear, &b.FiscalPeriod, &b.Version,
&b.DepartmentID, &b.GLAccountID, &b.Amount, &b.Currency,
&b.Notes, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("fetch updated budget: %w", err)
}
return b, nil
}
func (r *BudgetRepo) Delete(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id=?`, id)
if err != nil {
return fmt.Errorf("delete budget: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("budget %d not found", id)
}
return nil
}

42
internal/database/db.go Normal file
View File

@@ -0,0 +1,42 @@
package database
import (
"context"
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// DB wraps sql.DB so the rest of the codebase has a single type to depend on.
type DB struct {
*sql.DB
}
// Connect opens (or creates) the SQLite file at path and runs pragmas for
// safe, performant operation. Pass ":memory:" for tests.
func Connect(_ context.Context, path string) (*DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// WAL mode gives much better read concurrency without blocking reads
// during writes. foreign_keys must be enabled per-connection in SQLite.
pragmas := []string{
"PRAGMA journal_mode=WAL;",
"PRAGMA foreign_keys=ON;",
"PRAGMA busy_timeout=5000;",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
return nil, fmt.Errorf("pragma %q: %w", p, err)
}
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return &DB{db}, nil
}

View File

@@ -0,0 +1,86 @@
package database
import "fmt"
// Migrate runs the schema bootstrap. SQLite doesn't need a migration tool —
// CREATE TABLE IF NOT EXISTS is idempotent so this is safe to call on every start.
func Migrate(db *DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS departments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
cost_center TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)`,
`CREATE TABLE IF NOT EXISTS gl_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('revenue','cogs','opex','capex','headcount')),
favour_high INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fiscal_year INTEGER NOT NULL,
fiscal_period INTEGER NOT NULL CHECK(fiscal_period BETWEEN 1 AND 12),
version TEXT NOT NULL DEFAULT 'original'
CHECK(version IN ('original','forecast_1','forecast_2','forecast_3')),
department_id INTEGER NOT NULL REFERENCES departments(id),
gl_account_id INTEGER NOT NULL REFERENCES gl_accounts(id),
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'DKK',
notes TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(fiscal_year, fiscal_period, version, department_id, gl_account_id)
)`,
`CREATE TABLE IF NOT EXISTS actuals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fiscal_year INTEGER NOT NULL,
fiscal_period INTEGER NOT NULL CHECK(fiscal_period BETWEEN 1 AND 12),
department_id INTEGER NOT NULL REFERENCES departments(id),
gl_account_id INTEGER NOT NULL REFERENCES gl_accounts(id),
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'DKK',
source TEXT NOT NULL DEFAULT '',
ingested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(fiscal_year, fiscal_period, department_id, gl_account_id)
)`,
// Seed reference data if not already present
`INSERT OR IGNORE INTO departments (code, name, cost_center) VALUES
('ENG', 'Engineering', 'CC-100'),
('SALES', 'Sales', 'CC-200'),
('MKTG', 'Marketing', 'CC-300'),
('FINANCE', 'Finance & Operations', 'CC-400'),
('PRODUCT', 'Product', 'CC-500')`,
`INSERT OR IGNORE INTO gl_accounts (code, description, type, favour_high) VALUES
('4000', 'Subscription Revenue', 'revenue', 1),
('4100', 'Professional Services', 'revenue', 1),
('5000', 'Cloud Infrastructure', 'cogs', 0),
('5100', 'Third-party Licenses', 'cogs', 0),
('6100', 'Salaries & Wages', 'headcount', 0),
('6110', 'Employer Payroll Tax', 'headcount', 0),
('6120', 'Employee Benefits', 'headcount', 0),
('6300', 'Software Subscriptions', 'opex', 0),
('6310', 'Travel & Entertainment', 'opex', 0),
('6400', 'Marketing & Advertising', 'opex', 0),
('6500', 'Consulting & Contractors', 'opex', 0),
('6600', 'Office & Facilities', 'opex', 0)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"encoding/json"
"net/http"
"Engine/internal/database"
"Engine/internal/model"
)
type ActualsHandler struct {
repo *database.ActualsRepo
}
func NewActualsHandler(repo *database.ActualsRepo) *ActualsHandler {
return &ActualsHandler{repo: repo}
}
func (h *ActualsHandler) Ingest(w http.ResponseWriter, r *http.Request) {
var req model.IngestActualsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
actual, err := h.repo.Ingest(r.Context(), req)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, actual)
}

View File

@@ -0,0 +1,68 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"Engine/internal/model"
"Engine/internal/service"
)
type BudgetHandler struct {
svc *service.BudgetService
}
func NewBudgetHandler(svc *service.BudgetService) *BudgetHandler {
return &BudgetHandler{svc: svc}
}
func (h *BudgetHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateBudgetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
budget, err := h.svc.Create(r.Context(), req)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, budget)
}
func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Amount float64 `json:"amount"`
Notes string `json:"notes"`
ChangedBy string `json:"changed_by"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
budget, err := h.svc.Update(r.Context(), id, body.Amount, body.Notes, body.ChangedBy)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, budget)
}
func (h *BudgetHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,16 @@
package handler
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

View File

@@ -0,0 +1,64 @@
package handler
import (
"net/http"
"strconv"
"Engine/internal/model"
"Engine/internal/service"
)
type VarianceHandler struct {
svc *service.VarianceService
}
func NewVarianceHandler(svc *service.VarianceService) *VarianceHandler {
return &VarianceHandler{svc: svc}
}
func (h *VarianceHandler) Report(w http.ResponseWriter, r *http.Request) {
f := filterFromQuery(r)
report, err := h.svc.Report(r.Context(), f)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, report)
}
func (h *VarianceHandler) Alerts(w http.ResponseWriter, r *http.Request) {
f := filterFromQuery(r)
threshold := 10.0
if t := r.URL.Query().Get("threshold"); t != "" {
if v, err := strconv.ParseFloat(t, 64); err == nil {
threshold = v
}
}
alerts, err := h.svc.Alerts(r.Context(), f, threshold)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, alerts)
}
func filterFromQuery(r *http.Request) service.VarianceFilter {
q := r.URL.Query()
year, _ := strconv.Atoi(q.Get("year"))
period, _ := strconv.Atoi(q.Get("period"))
version := model.BudgetVersion(q.Get("version"))
if version == "" {
version = model.VersionOriginal
}
return service.VarianceFilter{
FiscalYear: year,
FiscalPeriod: period,
DeptCode: q.Get("dept"),
Version: version,
}
}

139
internal/model/model.go Normal file
View File

@@ -0,0 +1,139 @@
package model
import "time"
type GLAccountType string
const (
GLRevenue GLAccountType = "revenue"
GLCOGS GLAccountType = "cogs"
GLOpex GLAccountType = "opex"
GLCapex GLAccountType = "capex"
GLHeadcount GLAccountType = "headcount"
)
type BudgetVersion string
const (
VersionOriginal BudgetVersion = "original"
VersionForecast1 BudgetVersion = "forecast_1"
VersionForecast2 BudgetVersion = "forecast_2"
)
type Department struct {
ID int `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
CostCenter string `json:"cost_center"`
}
type GLAccount struct {
ID int `json:"id"`
Code string `json:"code"`
Description string `json:"description"`
Type GLAccountType `json:"type"`
FavourHigh bool `json:"favour_high"`
}
type Budget struct {
ID int `json:"id"`
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
Version BudgetVersion `json:"version"`
DepartmentID int `json:"department_id"`
GLAccountID int `json:"gl_account_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Notes string `json:"notes,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateBudgetRequest struct {
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
Version BudgetVersion `json:"version"`
DepartmentID int `json:"department_id"`
GLAccountID int `json:"gl_account_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Notes string `json:"notes"`
CreatedBy string `json:"created_by"`
}
type Actual struct {
ID int `json:"id"`
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
DepartmentID int `json:"department_id"`
GLAccountID int `json:"gl_account_id"`
GLCode string `json:"gl_code"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Source string `json:"source"`
IngestedAt time.Time `json:"ingested_at"`
}
type IngestActualsRequest struct {
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
DeptCode string `json:"dept_code"`
GLCode string `json:"gl_code"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Source string `json:"source"`
}
type VarianceStatus string
const (
StatusFavourable VarianceStatus = "favourable"
StatusUnfavourable VarianceStatus = "unfavourable"
StatusOnBudget VarianceStatus = "on_budget"
)
type VarianceLine struct {
GLCode string `json:"gl_code"`
GLDescription string `json:"gl_description"`
GLType GLAccountType `json:"gl_type"`
Budget float64 `json:"budget"`
Actual float64 `json:"actual"`
VarianceAbs float64 `json:"variance_abs"`
VariancePct *float64 `json:"variance_pct"`
Status VarianceStatus `json:"status"`
Currency string `json:"currency"`
}
type VarianceReport struct {
Department string `json:"department"`
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
Version BudgetVersion `json:"version"`
Currency string `json:"currency"`
Lines []VarianceLine `json:"lines"`
TotalBudget float64 `json:"total_budget"`
TotalActual float64 `json:"total_actual"`
TotalVariance float64 `json:"total_variance"`
VariancePct *float64 `json:"total_variance_pct"`
}
type AlertThreshold struct {
GLCode string `json:"gl_code"`
Description string `json:"description"`
Budget float64 `json:"budget"`
Actual float64 `json:"actual"`
VariancePct float64 `json:"variance_pct"`
Status VarianceStatus `json:"status"`
Department string `json:"department"`
}
// BudgetRow is used internally by the variance service
type BudgetRow struct {
GLCode string
GLDescription string
GLType GLAccountType
FavourHigh bool
Amount float64
Currency string
}

View File

@@ -0,0 +1,28 @@
package service
import (
"context"
"Engine/internal/database"
"Engine/internal/model"
)
type BudgetService struct {
repo *database.BudgetRepo
}
func NewBudgetService(repo *database.BudgetRepo) *BudgetService {
return &BudgetService{repo: repo}
}
func (s *BudgetService) Create(ctx context.Context, req model.CreateBudgetRequest) (*model.Budget, error) {
return s.repo.Create(ctx, req)
}
func (s *BudgetService) Update(ctx context.Context, id int, amount float64, notes, changedBy string) (*model.Budget, error) {
return s.repo.Update(ctx, id, amount, notes, changedBy)
}
func (s *BudgetService) Delete(ctx context.Context, id int) error {
return s.repo.Delete(ctx, id)
}

View File

@@ -0,0 +1,116 @@
package service
import (
"context"
"math"
"Engine/internal/database"
"Engine/internal/model"
)
type VarianceService struct {
budgets *database.BudgetRepo
actuals *database.ActualsRepo
}
func NewVarianceService(b *database.BudgetRepo, a *database.ActualsRepo) *VarianceService {
return &VarianceService{budgets: b, actuals: a}
}
type VarianceFilter struct {
FiscalYear int
FiscalPeriod int
DeptCode string
Version model.BudgetVersion
}
func (s *VarianceService) Report(ctx context.Context, f VarianceFilter) (*model.VarianceReport, error) {
budgets, err := s.budgets.List(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode, f.Version)
if err != nil {
return nil, err
}
actualsByGL, err := s.actuals.ListByPeriod(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode)
if err != nil {
return nil, err
}
report := &model.VarianceReport{
FiscalYear: f.FiscalYear,
FiscalPeriod: f.FiscalPeriod,
Version: f.Version,
Department: f.DeptCode,
Currency: "DKK",
}
for _, b := range budgets {
actual := actualsByGL[b.GLCode]
varAbs := actual - b.Amount
var varPct *float64
if b.Amount != 0 {
v := math.Round((varAbs/b.Amount)*10000) / 100
varPct = &v
}
report.Lines = append(report.Lines, model.VarianceLine{
GLCode: b.GLCode,
GLDescription: b.GLDescription,
GLType: b.GLType,
Budget: b.Amount,
Actual: actual,
VarianceAbs: varAbs,
VariancePct: varPct,
Status: computeStatus(varAbs, b.FavourHigh),
Currency: b.Currency,
})
report.TotalBudget += b.Amount
report.TotalActual += actual
}
report.TotalVariance = report.TotalActual - report.TotalBudget
if report.TotalBudget != 0 {
v := math.Round((report.TotalVariance/report.TotalBudget)*10000) / 100
report.VariancePct = &v
}
return report, nil
}
func (s *VarianceService) Alerts(ctx context.Context, f VarianceFilter, thresholdPct float64) ([]model.AlertThreshold, error) {
report, err := s.Report(ctx, f)
if err != nil {
return nil, err
}
var alerts []model.AlertThreshold
for _, line := range report.Lines {
if line.VariancePct == nil {
continue
}
if math.Abs(*line.VariancePct) >= thresholdPct {
alerts = append(alerts, model.AlertThreshold{
GLCode: line.GLCode,
Description: line.GLDescription,
Budget: line.Budget,
Actual: line.Actual,
VariancePct: *line.VariancePct,
Status: line.Status,
Department: report.Department,
})
}
}
return alerts, nil
}
func computeStatus(varAbs float64, favourHigh bool) model.VarianceStatus {
const epsilon = 0.01
if math.Abs(varAbs) < epsilon {
return model.StatusOnBudget
}
if (favourHigh && varAbs > 0) || (!favourHigh && varAbs < 0) {
return model.StatusFavourable
}
return model.StatusUnfavourable
}