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
}