better end points and better tests

This commit is contained in:
samantha42
2026-03-21 15:47:40 +01:00
parent 3f203178b2
commit 3490dd13d4
10 changed files with 482 additions and 34 deletions

View File

@@ -38,20 +38,28 @@ func (r *ActualsRepo) Ingest(ctx context.Context, req model.IngestActualsRequest
}
a := &model.Actual{}
var ingestedAt string
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=?`,
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,
&a.Amount, &a.Currency, &a.Source, &ingestedAt,
)
if err != nil {
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
}
@@ -82,3 +90,15 @@ func (r *ActualsRepo) ListByPeriod(ctx context.Context, fiscalYear, fiscalPeriod
}
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
View 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)
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"net/http"
"strings"
"Engine/internal/database"
"Engine/internal/model"
@@ -23,6 +24,12 @@ func (h *ActualsHandler) Ingest(w http.ResponseWriter, r *http.Request) {
return
}
errs := req.Valid()
if len(errs) > 0 {
writeError(w, http.StatusBadRequest, strings.Join(errs, "; "))
return
}
actual, err := h.repo.Ingest(r.Context(), req)
if err != nil {
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)
}
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)
}

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

View File

@@ -276,3 +276,57 @@ type BudgetRow struct {
Amount float64
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
}

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