basic online
This commit is contained in:
83
internal/database/actual-repo.go
Normal file
83
internal/database/actual-repo.go
Normal 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()
|
||||
}
|
||||
116
internal/database/budget-repo.go
Normal file
116
internal/database/budget-repo.go
Normal 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
42
internal/database/db.go
Normal 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
|
||||
}
|
||||
86
internal/database/migrate.go
Normal file
86
internal/database/migrate.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user