better end points and better tests
This commit is contained in:
@@ -38,20 +38,28 @@ func (r *ActualsRepo) Ingest(ctx context.Context, req model.IngestActualsRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
a := &model.Actual{}
|
a := &model.Actual{}
|
||||||
|
var ingestedAt string
|
||||||
|
|
||||||
err = r.db.QueryRowContext(ctx, `
|
err = r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, fiscal_year, fiscal_period, department_id, gl_account_id,
|
SELECT id, fiscal_year, fiscal_period, department_id, gl_account_id,
|
||||||
amount, currency, source, ingested_at
|
amount, currency, source, ingested_at
|
||||||
FROM actuals
|
FROM actuals
|
||||||
WHERE fiscal_year=? AND fiscal_period=? AND department_id=? AND gl_account_id=?`,
|
WHERE fiscal_year=? AND fiscal_period=? AND department_id=? AND gl_account_id=?`,
|
||||||
req.FiscalYear, req.FiscalPeriod, deptID, glID,
|
req.FiscalYear, req.FiscalPeriod, deptID, glID,
|
||||||
).Scan(
|
).Scan(
|
||||||
&a.ID, &a.FiscalYear, &a.FiscalPeriod,
|
&a.ID, &a.FiscalYear, &a.FiscalPeriod,
|
||||||
&a.DepartmentID, &a.GLAccountID,
|
&a.DepartmentID, &a.GLAccountID,
|
||||||
&a.Amount, &a.Currency, &a.Source, &a.IngestedAt,
|
&a.Amount, &a.Currency, &a.Source, &ingestedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch ingested actual: %w", err)
|
return nil, fmt.Errorf("fetch ingested actual: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.IngestedAt, err = parseTime(ingestedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ingested_at %q: %w", ingestedAt, err)
|
||||||
|
}
|
||||||
|
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,3 +90,15 @@ func (r *ActualsRepo) ListByPeriod(ctx context.Context, fiscalYear, fiscalPeriod
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ActualsRepo) IngestBatch(ctx context.Context, reqs []model.IngestActualsRequest) ([]*model.Actual, error) {
|
||||||
|
out := make([]*model.Actual, 0, len(reqs))
|
||||||
|
for _, req := range reqs {
|
||||||
|
a, err := r.Ingest(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IngestBatch: record (dept=%s gl=%s): %w", req.DeptCode, req.GLCode, err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
144
internal/database/report.go
Normal file
144
internal/database/report.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Engine/internal/model"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportRepo(db *sql.DB) *ReportRepo {
|
||||||
|
return &ReportRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAmountsByGLType is the shared query for both actuals and budget tables,
|
||||||
|
// filtered to a single GL account type, period, and department.
|
||||||
|
func (rr *ReportRepo) getActualAmountsByGLType(
|
||||||
|
ctx context.Context,
|
||||||
|
accountType model.GLAccountType,
|
||||||
|
fiscalYear, fiscalPeriod int,
|
||||||
|
deptCode string,
|
||||||
|
) ([]model.GLAmountRow, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT g.code,
|
||||||
|
g.description,
|
||||||
|
SUM(a.amount),
|
||||||
|
a.currency
|
||||||
|
FROM actuals a
|
||||||
|
JOIN gl_accounts g ON g.id = a.gl_account_id
|
||||||
|
JOIN departments d ON d.id = a.department_id
|
||||||
|
WHERE g.type = ?
|
||||||
|
AND g.active = 1
|
||||||
|
AND a.fiscal_year = ?
|
||||||
|
AND a.fiscal_period = ?
|
||||||
|
AND d.code = ?
|
||||||
|
GROUP BY g.id, a.currency
|
||||||
|
ORDER BY g.code`
|
||||||
|
|
||||||
|
rows, err := rr.db.QueryContext(ctx, q,
|
||||||
|
string(accountType), fiscalYear, fiscalPeriod, deptCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getActualAmountsByGLType(%s): %w", accountType, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []model.GLAmountRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r model.GLAmountRow
|
||||||
|
if err := rows.Scan(&r.GLCode, &r.Description, &r.Amount, &r.Currency); err != nil {
|
||||||
|
return nil, fmt.Errorf("getActualAmountsByGLType(%s): scan: %w", accountType, err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) getBudgetAmountsByGLType(
|
||||||
|
ctx context.Context,
|
||||||
|
accountType model.GLAccountType,
|
||||||
|
fiscalYear, fiscalPeriod int,
|
||||||
|
deptCode string,
|
||||||
|
version model.BudgetVersion,
|
||||||
|
) ([]model.RevenueAmounts, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT g.code,
|
||||||
|
g.description,
|
||||||
|
SUM(b.amount),
|
||||||
|
b.currency
|
||||||
|
FROM budgets b
|
||||||
|
JOIN gl_accounts g ON g.id = b.gl_account_id
|
||||||
|
JOIN departments d ON d.id = b.department_id
|
||||||
|
WHERE g.type = ?
|
||||||
|
AND g.active = 1
|
||||||
|
AND b.fiscal_year = ?
|
||||||
|
AND b.fiscal_period = ?
|
||||||
|
AND d.code = ?
|
||||||
|
AND b.version = ?
|
||||||
|
GROUP BY g.id, b.currency
|
||||||
|
ORDER BY g.code`
|
||||||
|
|
||||||
|
rows, err := rr.db.QueryContext(ctx, q,
|
||||||
|
string(accountType), fiscalYear, fiscalPeriod, deptCode, string(version))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getBudgetAmountsByGLType(%s): %w", accountType, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []model.RevenueAmounts
|
||||||
|
for rows.Next() {
|
||||||
|
var r model.RevenueAmounts
|
||||||
|
if err := rows.Scan(&r.GLCode, &r.Description, &r.Amount, &r.Currency); err != nil {
|
||||||
|
return nil, fmt.Errorf("getBudgetAmountsByGLType(%s): scan: %w", accountType, err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public surface ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLRevenueActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) {
|
||||||
|
return rr.getActualAmountsByGLType(ctx, model.GLRevenue, fiscalYear, fiscalPeriod, deptCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLRevenueBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) {
|
||||||
|
return rr.getBudgetAmountsByGLType(ctx, model.GLRevenue, fiscalYear, fiscalPeriod, deptCode, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same pair for the other types — all delegate to the shared private methods.
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLCOGSActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) {
|
||||||
|
return rr.getActualAmountsByGLType(ctx, model.GLCOGS, fiscalYear, fiscalPeriod, deptCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLCOGSBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) {
|
||||||
|
return rr.getBudgetAmountsByGLType(ctx, model.GLCOGS, fiscalYear, fiscalPeriod, deptCode, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLOpexActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) {
|
||||||
|
return rr.getActualAmountsByGLType(ctx, model.GLOpex, fiscalYear, fiscalPeriod, deptCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLOpexBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) {
|
||||||
|
return rr.getBudgetAmountsByGLType(ctx, model.GLOpex, fiscalYear, fiscalPeriod, deptCode, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLCapexActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) {
|
||||||
|
return rr.getActualAmountsByGLType(ctx, model.GLCapex, fiscalYear, fiscalPeriod, deptCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLCapexBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) {
|
||||||
|
return rr.getBudgetAmountsByGLType(ctx, model.GLCapex, fiscalYear, fiscalPeriod, deptCode, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLHeadcountActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) {
|
||||||
|
return rr.getActualAmountsByGLType(ctx, model.GLHeadcount, fiscalYear, fiscalPeriod, deptCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *ReportRepo) GetGLHeadcountBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) {
|
||||||
|
return rr.getBudgetAmountsByGLType(ctx, model.GLHeadcount, fiscalYear, fiscalPeriod, deptCode, version)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"Engine/internal/database"
|
"Engine/internal/database"
|
||||||
"Engine/internal/model"
|
"Engine/internal/model"
|
||||||
@@ -23,6 +24,12 @@ func (h *ActualsHandler) Ingest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errs := req.Valid()
|
||||||
|
if len(errs) > 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, strings.Join(errs, "; "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
actual, err := h.repo.Ingest(r.Context(), req)
|
actual, err := h.repo.Ingest(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -31,3 +38,28 @@ func (h *ActualsHandler) Ingest(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusCreated, actual)
|
writeJSON(w, http.StatusCreated, actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ActualsHandler) IngestBatch(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
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
for _, atual := range req {
|
||||||
|
errs = append(errs, atual.Valid()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, strings.Join(errs, "; "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actual, err := h.repo.IngestBatch(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, actual)
|
||||||
|
}
|
||||||
|
|||||||
40
internal/handler/report.go
Normal file
40
internal/handler/report.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Engine/internal/model"
|
||||||
|
"Engine/internal/service"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportHandler struct {
|
||||||
|
svc *service.ReportService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportHandler(svc *service.ReportService) *ReportHandler {
|
||||||
|
return &ReportHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (report *ReportHandler) PnL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.PnLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, year := range req.FiscalYears {
|
||||||
|
for _, period := range req.FiscalPeriods {
|
||||||
|
for _, Department := range req.DeptCodes {
|
||||||
|
report.svc.CreatePnL(context.TODO(), service.PnLFilter{
|
||||||
|
FiscalYear: year,
|
||||||
|
FiscalPeriod: period,
|
||||||
|
DeptCode: Department,
|
||||||
|
Version: req.Version,
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -276,3 +276,57 @@ type BudgetRow struct {
|
|||||||
Amount float64
|
Amount float64
|
||||||
Currency string
|
Currency string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GLAmountRow is a single GL line returned by the ReportRepo amount queries.
|
||||||
|
// Used as the building block for PnLSection lines.
|
||||||
|
type GLAmountRow struct {
|
||||||
|
GLCode string `json:"gl_code"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevenueAmounts holds the per-GL amounts for revenue accounts in a single period.
|
||||||
|
// Used as input for P&L rollup and reforecast calculations.
|
||||||
|
type RevenueAmounts struct {
|
||||||
|
GLCode string
|
||||||
|
Description string
|
||||||
|
Amount float64
|
||||||
|
Currency string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PnLSection is one block in the P&L (Revenue, COGS, Opex, etc.)
|
||||||
|
// Lines are the individual GL rows; Total is their sum.
|
||||||
|
type PnLSection struct {
|
||||||
|
Lines []GLAmountRow `json:"lines"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PnLReport is the full P&L for a single period/dept/version.
|
||||||
|
// GrossProfit = Revenue - COGS
|
||||||
|
// EBIT = GrossProfit - Opex - Headcount
|
||||||
|
// NetIncome = EBIT - Capex
|
||||||
|
type PnLReport struct {
|
||||||
|
Department string `json:"department"`
|
||||||
|
FiscalYear int `json:"fiscal_year"`
|
||||||
|
FiscalPeriod int `json:"fiscal_period"`
|
||||||
|
Version BudgetVersion `json:"version"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
|
||||||
|
Revenue PnLSection `json:"revenue"`
|
||||||
|
COGS PnLSection `json:"cogs"`
|
||||||
|
Opex PnLSection `json:"opex"`
|
||||||
|
Headcount PnLSection `json:"headcount"`
|
||||||
|
Capex PnLSection `json:"capex"`
|
||||||
|
|
||||||
|
GrossProfit float64 `json:"gross_profit"` // Revenue - COGS
|
||||||
|
EBIT float64 `json:"ebit"` // GrossProfit - Opex - Headcount
|
||||||
|
NetIncome float64 `json:"net_income"` // EBIT - Capex
|
||||||
|
}
|
||||||
|
|
||||||
|
type PnLRequest struct {
|
||||||
|
FiscalYears []int
|
||||||
|
FiscalPeriods []int
|
||||||
|
DeptCodes []string
|
||||||
|
Version BudgetVersion
|
||||||
|
}
|
||||||
|
|||||||
82
internal/service/report-service.go
Normal file
82
internal/service/report-service.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"Engine/internal/database"
|
||||||
|
"Engine/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportService struct {
|
||||||
|
repo *database.ReportRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportService(repo *database.ReportRepo) *ReportService {
|
||||||
|
return &ReportService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PnLFilter struct {
|
||||||
|
FiscalYear int
|
||||||
|
FiscalPeriod int
|
||||||
|
DeptCode string
|
||||||
|
Version model.BudgetVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReportService) CreatePnL(ctx context.Context, f PnLFilter) (*model.PnLReport, error) {
|
||||||
|
// fetch all five GL type buckets in parallel would be nicer, but keeping
|
||||||
|
// it simple and sequential matches the rest of your codebase style.
|
||||||
|
|
||||||
|
revenue, err := s.repo.GetGLRevenueActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CreatePnL: revenue actuals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cogs, err := s.repo.GetGLCOGSActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CreatePnL: cogs actuals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opex, err := s.repo.GetGLOpexActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CreatePnL: opex actuals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headcount, err := s.repo.GetGLHeadcountActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CreatePnL: headcount actuals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
capex, err := s.repo.GetGLCapexActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CreatePnL: capex actuals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := &model.PnLReport{
|
||||||
|
Department: f.DeptCode,
|
||||||
|
FiscalYear: f.FiscalYear,
|
||||||
|
FiscalPeriod: f.FiscalPeriod,
|
||||||
|
Version: f.Version,
|
||||||
|
Currency: "DKK",
|
||||||
|
Revenue: toSection(revenue),
|
||||||
|
COGS: toSection(cogs),
|
||||||
|
Opex: toSection(opex),
|
||||||
|
Headcount: toSection(headcount),
|
||||||
|
Capex: toSection(capex),
|
||||||
|
}
|
||||||
|
|
||||||
|
report.GrossProfit = report.Revenue.Total - report.COGS.Total
|
||||||
|
report.EBIT = report.GrossProfit - report.Opex.Total - report.Headcount.Total
|
||||||
|
report.NetIncome = report.EBIT - report.Capex.Total
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSection sums the rows and packages them into a PnLSection.
|
||||||
|
func toSection(rows []model.GLAmountRow) model.PnLSection {
|
||||||
|
s := model.PnLSection{Lines: rows}
|
||||||
|
for _, r := range rows {
|
||||||
|
s.Total += r.Amount
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
18
main.go
18
main.go
@@ -46,6 +46,9 @@ func main() {
|
|||||||
budgetH := handler.NewBudgetHandler(budgetSvc)
|
budgetH := handler.NewBudgetHandler(budgetSvc)
|
||||||
actualsH := handler.NewActualsHandler(actualsRepo)
|
actualsH := handler.NewActualsHandler(actualsRepo)
|
||||||
varianceH := handler.NewVarianceHandler(varianceSvc)
|
varianceH := handler.NewVarianceHandler(varianceSvc)
|
||||||
|
reportRepo := database.NewReportRepo(db)
|
||||||
|
reportSvc := service.NewReportService(reportRepo)
|
||||||
|
reportH := handler.NewReportHandler(reportSvc)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Reference endpoints
|
// Reference endpoints
|
||||||
@@ -56,20 +59,25 @@ func main() {
|
|||||||
mux.HandleFunc("GET /api/v1/department/actual", referenceH.ListDepartments)
|
mux.HandleFunc("GET /api/v1/department/actual", referenceH.ListDepartments)
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/v1/gl-account/create", referenceH.CreateGLAccount)
|
mux.HandleFunc("POST /api/v1/gl-account/create", referenceH.CreateGLAccount)
|
||||||
mux.HandleFunc("DELETE /api/v1/gl-accounts/delete", referenceH.DeleteGLAccount)
|
mux.HandleFunc("DELETE /api/v1/gl-account/delete", referenceH.DeleteGLAccount)
|
||||||
mux.HandleFunc("GET /api/v1/gl-account/list", referenceH.ListGLAccounts)
|
mux.HandleFunc("GET /api/v1/gl-account/list", referenceH.ListGLAccounts)
|
||||||
mux.HandleFunc("GET /api/v1/gl-accounts/bugdet", referenceH.ListDepartments)
|
mux.HandleFunc("GET /api/v1/gl-account/bugdet", referenceH.ListDepartments)
|
||||||
mux.HandleFunc("GET /api/v1/gl-accounts/actual", referenceH.ListDepartments)
|
mux.HandleFunc("GET /api/v1/gl-account/actual", referenceH.ListDepartments)
|
||||||
|
|
||||||
// Budget endpoints
|
// Budget endpoints
|
||||||
mux.HandleFunc("POST /api/v1/budget/create", budgetH.Create)
|
mux.HandleFunc("POST /api/v1/budget/create", budgetH.Create)
|
||||||
mux.HandleFunc("PUT /api/v1/budgets/update", budgetH.Update)
|
mux.HandleFunc("PUT /api/v1/budget/update", budgetH.Update)
|
||||||
mux.HandleFunc("DELETE /api/v1/budgets/delete", budgetH.Delete)
|
mux.HandleFunc("DELETE /api/v1/budget/delete", budgetH.Delete)
|
||||||
|
|
||||||
// Actuals + variance
|
// Actuals + variance
|
||||||
mux.HandleFunc("POST /api/v1/actuals/ingest", actualsH.Ingest)
|
mux.HandleFunc("POST /api/v1/actuals/ingest", actualsH.Ingest)
|
||||||
|
mux.HandleFunc("POST /api/v1/actuals/ingest/batch", actualsH.IngestBatch)
|
||||||
mux.HandleFunc("GET /api/v1/variance", varianceH.Report)
|
mux.HandleFunc("GET /api/v1/variance", varianceH.Report)
|
||||||
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)
|
||||||
|
|
||||||
|
//reports
|
||||||
|
mux.HandleFunc("GET /api/v1/reports/pnl", reportH.PnL)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := db.PingContext(r.Context()); err != nil {
|
if err := db.PingContext(r.Context()); err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"Engine/internal/database"
|
"Engine/internal/database"
|
||||||
"Engine/internal/handler"
|
"Engine/internal/handler"
|
||||||
|
"Engine/internal/model"
|
||||||
"Engine/internal/service"
|
"Engine/internal/service"
|
||||||
"Engine/tests/internal/testutil"
|
"Engine/tests/internal/testutil"
|
||||||
)
|
)
|
||||||
@@ -39,18 +40,15 @@ func newFullServer(t *testing.T) *httptest.Server {
|
|||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
func newActualsHandler(t *testing.T) *handler.ActualsHandler {
|
|
||||||
t.Helper()
|
|
||||||
return handler.NewActualsHandler(database.NewActualsRepo(testutil.NewTestDB(t)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// validActual returns one well-formed actual record.
|
// validActual returns one well-formed actual record.
|
||||||
func validActual() map[string]any {
|
func validActual() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"department_id": 1,
|
"dept_code": "TEST",
|
||||||
"gl_account_id": 1,
|
"gl_code": "TEST",
|
||||||
"period": "2024-01",
|
"fiscal_year": 2024,
|
||||||
|
"fiscal_period": 1,
|
||||||
"amount": 1234.56,
|
"amount": 1234.56,
|
||||||
|
"currency": "USD",
|
||||||
"source": "csv_import",
|
"source": "csv_import",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,43 +56,72 @@ func validActual() map[string]any {
|
|||||||
// ── Actuals: Ingest ───────────────────────────────────────────────────────────
|
// ── Actuals: Ingest ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestIngestActuals_SingleRecord(t *testing.T) {
|
func TestIngestActuals_SingleRecord(t *testing.T) {
|
||||||
h := newActualsHandler(t)
|
t.Helper()
|
||||||
|
db := testutil.NewTestDB(t)
|
||||||
|
repo := database.NewActualsRepo(db)
|
||||||
|
h := handler.NewActualsHandler(repo)
|
||||||
|
|
||||||
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/",
|
// makes sire the dept and gl entries exists for testing
|
||||||
[]any{validActual()})
|
testutil.SeedFixtures(t, db) // seld error handling/return
|
||||||
|
|
||||||
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", validActual())
|
||||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||||
|
|
||||||
|
var got model.Actual
|
||||||
|
testutil.DecodeJSON(t, w, &got)
|
||||||
|
if got.ID == 0 {
|
||||||
|
t.Error("expected non-zero ID")
|
||||||
|
}
|
||||||
|
if got.Amount != 1234.56 {
|
||||||
|
t.Errorf("amount: got %v, want 1234.56", got.Amount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestActuals_MultipleRecords(t *testing.T) {
|
func TestIngestActuals_MultipleRecords(t *testing.T) {
|
||||||
h := newActualsHandler(t)
|
db := testutil.NewTestDB(t)
|
||||||
|
testutil.SeedFixtures(t, db)
|
||||||
|
h := handler.NewActualsHandler(database.NewActualsRepo(db))
|
||||||
|
|
||||||
records := []any{
|
records := []any{
|
||||||
map[string]any{"department_id": 1, "gl_account_id": 1, "period": "2024-01", "amount": 100.00, "source": "csv"},
|
map[string]any{"dept_code": "TEST", "gl_code": "TEST", "fiscal_year": 2024, "fiscal_period": 1, "amount": 100.00, "currency": "DKK", "source": "csv"},
|
||||||
map[string]any{"department_id": 1, "gl_account_id": 2, "period": "2024-01", "amount": 200.00, "source": "csv"},
|
map[string]any{"dept_code": "TEST", "gl_code": "TEST", "fiscal_year": 2024, "fiscal_period": 2, "amount": 200.00, "currency": "DKK", "source": "csv"},
|
||||||
map[string]any{"department_id": 2, "gl_account_id": 1, "period": "2024-01", "amount": 300.00, "source": "csv"},
|
map[string]any{"dept_code": "TEST", "gl_code": "TEST", "fiscal_year": 2024, "fiscal_period": 3, "amount": 300.00, "currency": "DKK", "source": "csv"},
|
||||||
}
|
}
|
||||||
|
|
||||||
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", records)
|
w := testutil.Do(t, http.HandlerFunc(h.IngestBatch), http.MethodPost, "/", records)
|
||||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||||
|
|
||||||
|
var got []model.Actual
|
||||||
|
testutil.DecodeJSON(t, w, &got)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Errorf("expected 3 results, got %d", len(got))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestActuals_EmptyList(t *testing.T) {
|
func TestIngestActuals_EmptyList(t *testing.T) {
|
||||||
h := newActualsHandler(t)
|
t.Helper()
|
||||||
|
db := testutil.NewTestDB(t)
|
||||||
|
repo := database.NewActualsRepo(db)
|
||||||
|
h := handler.NewActualsHandler(repo)
|
||||||
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{})
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{})
|
||||||
// Depending on your handler: 201 with 0 rows ingested, or 400
|
// Depending on your handler: 201 with 0 rows ingested, or 400
|
||||||
t.Logf("empty ingest: %d — verify against your handler", w.Code)
|
t.Logf("empty ingest: %d — verify against your handler", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestActuals_InvalidJSON(t *testing.T) {
|
func TestIngestActuals_InvalidJSON(t *testing.T) {
|
||||||
h := newActualsHandler(t)
|
t.Helper()
|
||||||
|
db := testutil.NewTestDB(t)
|
||||||
|
repo := database.NewActualsRepo(db)
|
||||||
|
h := handler.NewActualsHandler(repo)
|
||||||
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", nil)
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", nil)
|
||||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestActuals_MissingPeriod(t *testing.T) {
|
func TestIngestActuals_MissingPeriod(t *testing.T) {
|
||||||
h := newActualsHandler(t)
|
t.Helper()
|
||||||
|
db := testutil.NewTestDB(t)
|
||||||
|
repo := database.NewActualsRepo(db)
|
||||||
|
h := handler.NewActualsHandler(repo)
|
||||||
record := validActual()
|
record := validActual()
|
||||||
delete(record, "period")
|
delete(record, "period")
|
||||||
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{record})
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{record})
|
||||||
@@ -102,7 +129,10 @@ func TestIngestActuals_MissingPeriod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestActuals_NegativeAmount(t *testing.T) {
|
func TestIngestActuals_NegativeAmount(t *testing.T) {
|
||||||
h := newActualsHandler(t)
|
t.Helper()
|
||||||
|
db := testutil.NewTestDB(t)
|
||||||
|
repo := database.NewActualsRepo(db)
|
||||||
|
h := handler.NewActualsHandler(repo)
|
||||||
record := validActual()
|
record := validActual()
|
||||||
record["amount"] = -500.00
|
record["amount"] = -500.00
|
||||||
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{record})
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{record})
|
||||||
@@ -111,7 +141,10 @@ func TestIngestActuals_NegativeAmount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestActuals_Idempotent(t *testing.T) {
|
func TestIngestActuals_Idempotent(t *testing.T) {
|
||||||
h := newActualsHandler(t)
|
t.Helper()
|
||||||
|
db := testutil.NewTestDB(t)
|
||||||
|
repo := database.NewActualsRepo(db)
|
||||||
|
h := handler.NewActualsHandler(repo)
|
||||||
fn := http.HandlerFunc(h.Ingest)
|
fn := http.HandlerFunc(h.Ingest)
|
||||||
|
|
||||||
testutil.Do(t, fn, http.MethodPost, "/", []any{validActual()})
|
testutil.Do(t, fn, http.MethodPost, "/", []any{validActual()})
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ func newBudgetServer(t *testing.T) *httptest.Server {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /api/v1/budget/create", h.Create)
|
mux.HandleFunc("POST /api/v1/budget/create", h.Create)
|
||||||
mux.HandleFunc("PUT /api/v1/budgets/update", h.Update)
|
mux.HandleFunc("PUT /api/v1/budget/update", h.Update)
|
||||||
mux.HandleFunc("DELETE /api/v1/budgets/delete", h.Delete)
|
mux.HandleFunc("DELETE /api/v1/budget/delete", h.Delete)
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|||||||
@@ -131,3 +131,38 @@ func DecodeJSON(t *testing.T, w *httptest.ResponseRecorder, dst any) {
|
|||||||
t.Fatalf("DecodeJSON: %v\n\tbody: %s", err, w.Body.String())
|
t.Fatalf("DecodeJSON: %v\n\tbody: %s", err, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedFixtures inserts one department and one GL account into db and returns
|
||||||
|
// their IDs. Call this at the top of any test that needs FK-valid actuals or
|
||||||
|
// budget rows.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// deptID, glID := testutil.SeedFixtures(t, db)
|
||||||
|
func SeedFixtures(t *testing.T, db *sql.DB) (deptID int, glAccountID int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := db.Exec(`
|
||||||
|
INSERT INTO departments (code, name, cost_center, active)
|
||||||
|
VALUES ('TEST', 'Test Department', 'CC-TEST', 1)
|
||||||
|
ON CONFLICT(code) DO UPDATE SET name = excluded.name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeedFixtures: insert department: %v", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
deptID = int(id)
|
||||||
|
|
||||||
|
res, err = db.Exec(`
|
||||||
|
INSERT INTO gl_accounts (code, description, type, favour_high, active)
|
||||||
|
VALUES ('TEST', 'Test Revenue', 'revenue', 1, 1)
|
||||||
|
ON CONFLICT(code) DO UPDATE SET description = excluded.description
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeedFixtures: insert gl_account: %v", err)
|
||||||
|
}
|
||||||
|
id, _ = res.LastInsertId()
|
||||||
|
glAccountID = int(id)
|
||||||
|
|
||||||
|
return deptID, glAccountID
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user