diff --git a/internal/database/actual-repo.go b/internal/database/actual-repo.go index 21fc749..0292f26 100644 --- a/internal/database/actual-repo.go +++ b/internal/database/actual-repo.go @@ -2,16 +2,17 @@ package database import ( "context" + "database/sql" "fmt" "Engine/internal/model" ) type ActualsRepo struct { - db *DB + db *sql.DB } -func NewActualsRepo(db *DB) *ActualsRepo { +func NewActualsRepo(db *sql.DB) *ActualsRepo { return &ActualsRepo{db: db} } diff --git a/internal/database/budget-repo.go b/internal/database/budget-repo.go index 7c83823..6422a72 100644 --- a/internal/database/budget-repo.go +++ b/internal/database/budget-repo.go @@ -2,23 +2,78 @@ package database import ( "context" + "database/sql" "fmt" + "time" "Engine/internal/model" ) -type BudgetRepo struct { - db *DB +// SQLite stores timestamps as TEXT in the format written by +// strftime('%Y-%m-%dT%H:%M:%SZ','now'). +// modernc.org/sqlite does not auto-convert TEXT → time.Time, so we scan +// into string and parse at the repo boundary with parseTime(). + +const sqliteTimeLayout = "2006-01-02T15:04:05Z" + +func parseTime(s string) (time.Time, error) { + t, err := time.Parse(sqliteTimeLayout, s) + if err != nil { + // fallback: SQLite can also produce "2006-01-02 15:04:05" + t, err = time.Parse("2006-01-02 15:04:05", s) + } + return t, err } -func NewBudgetRepo(db *DB) *BudgetRepo { +// scanBudget scans a budgets row into model.Budget. +// Accepts the 12 columns in SELECT order: +// +// id, fiscal_year, fiscal_period, version, department_id, gl_account_id, +// amount, currency, notes, created_by, created_at, updated_at +func scanBudget(row interface { + Scan(dest ...any) error +}) (*model.Budget, error) { + var b model.Budget + var createdAt, updatedAt string + + err := row.Scan( + &b.ID, &b.FiscalYear, &b.FiscalPeriod, &b.Version, + &b.DepartmentID, &b.GLAccountID, &b.Amount, &b.Currency, + &b.Notes, &b.CreatedBy, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + + if b.CreatedAt, err = parseTime(createdAt); err != nil { + return nil, fmt.Errorf("parse created_at %q: %w", createdAt, err) + } + if b.UpdatedAt, err = parseTime(updatedAt); err != nil { + return nil, fmt.Errorf("parse updated_at %q: %w", updatedAt, err) + } + + return &b, nil +} + +// ── BudgetRepo ──────────────────────────────────────────────────────────────── + +type BudgetRepo struct { + db *sql.DB +} + +func NewBudgetRepo(db *sql.DB) *BudgetRepo { return &BudgetRepo{db: db} } +const budgetSelectCols = ` + id, fiscal_year, fiscal_period, version, department_id, gl_account_id, + amount, currency, notes, created_by, created_at, updated_at` + 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) + (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, @@ -28,76 +83,35 @@ func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest) return nil, fmt.Errorf("create budget: %w", err) } - id, _ := res.LastInsertId() + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("last insert id: %w", 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, - ) + row := r.db.QueryRowContext(ctx, + `SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id) + b, err := scanBudget(row) 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) { +func (r *BudgetRepo) Update(ctx context.Context, id int, req model.UpdateBudgetRequest) (*model.Budget, error) { _, err := r.db.ExecContext(ctx, ` UPDATE budgets - SET amount=?, notes=?, updated_at=CURRENT_TIMESTAMP + SET version=?, amount=?, notes=?, + updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?`, - amount, notes, id, + req.Version, req.Amount, req.Notes, id, ) if err != nil { - return nil, fmt.Errorf("update budget %d: %w", id, err) + return nil, fmt.Errorf("update budget: %w", 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, - ) + row := r.db.QueryRowContext(ctx, + `SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id) + b, err := scanBudget(row) if err != nil { return nil, fmt.Errorf("fetch updated budget: %w", err) } @@ -105,12 +119,95 @@ func (r *BudgetRepo) Update(ctx context.Context, id int, amount float64, notes, } func (r *BudgetRepo) Delete(ctx context.Context, id int) error { - res, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id=?`, id) + _, 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 } + +func (r *BudgetRepo) GetByID(ctx context.Context, id int) (*model.Budget, error) { + row := r.db.QueryRowContext(ctx, + `SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id) + b, err := scanBudget(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get budget: %w", err) + } + return b, nil +} + +func (r *BudgetRepo) ListByPeriod(ctx context.Context, fiscalYear, fiscalPeriod int, version model.BudgetVersion) ([]model.Budget, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT`+budgetSelectCols+` + FROM budgets + WHERE fiscal_year=? AND fiscal_period=? AND version=? + ORDER BY department_id, gl_account_id`, + fiscalYear, fiscalPeriod, version, + ) + if err != nil { + return nil, fmt.Errorf("list budgets: %w", err) + } + defer rows.Close() + + var budgets []model.Budget + for rows.Next() { + b, err := scanBudget(rows) + if err != nil { + return nil, fmt.Errorf("scan budget row: %w", err) + } + budgets = append(budgets, *b) + } + return budgets, rows.Err() +} + +// List returns BudgetRow slice joined with gl_accounts — used by VarianceService. +// Filters by fiscal_year, fiscal_period, version, and optionally dept_code +// (matched against departments.code via JOIN). +func (r *BudgetRepo) List( + ctx context.Context, + fiscalYear, fiscalPeriod int, + deptCode string, + version model.BudgetVersion, +) ([]model.BudgetRow, error) { + q := ` + SELECT + g.code, + g.description, + g.type, + g.favour_high, + 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 b.fiscal_year = ? + AND b.fiscal_period = ? + AND b.version = ? + AND d.code = ? + ORDER BY g.code` + + rows, err := r.db.QueryContext(ctx, q, + fiscalYear, fiscalPeriod, version, deptCode) + if err != nil { + return nil, fmt.Errorf("budget list: %w", err) + } + defer rows.Close() + + var out []model.BudgetRow + for rows.Next() { + var br model.BudgetRow + var favourHigh int + if err := rows.Scan( + &br.GLCode, &br.GLDescription, &br.GLType, + &favourHigh, &br.Amount, &br.Currency, + ); err != nil { + return nil, fmt.Errorf("budget list scan: %w", err) + } + br.FavourHigh = favourHigh == 1 + out = append(out, br) + } + return out, rows.Err() +} diff --git a/internal/database/db.go b/internal/database/db.go index e8b8322..8e18699 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -15,7 +15,7 @@ type DB struct { // 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) { +func Connect(_ context.Context, path string) (*sql.DB, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) @@ -38,5 +38,5 @@ func Connect(_ context.Context, path string) (*DB, error) { return nil, fmt.Errorf("ping sqlite: %w", err) } - return &DB{db}, nil + return db, nil } diff --git a/internal/database/migrate.go b/internal/database/migrate.go index a492d41..f6fd317 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -1,10 +1,13 @@ package database -import "fmt" +import ( + "database/sql" + "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 { +func Migrate(db *sql.DB) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS departments ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/database/refrence-repo.go b/internal/database/refrence-repo.go index 0e21684..8ad58dc 100644 --- a/internal/database/refrence-repo.go +++ b/internal/database/refrence-repo.go @@ -2,6 +2,7 @@ package database import ( "context" + "database/sql" "fmt" ) @@ -28,10 +29,10 @@ type GLAccount struct { // ── ReferenceRepo ───────────────────────────────────────────────────────────── type ReferenceRepo struct { - db *DB + db *sql.DB } -func NewReferenceRepo(db *DB) *ReferenceRepo { +func NewReferenceRepo(db *sql.DB) *ReferenceRepo { return &ReferenceRepo{db: db} } diff --git a/internal/handler/budget.go b/internal/handler/budget.go index 5fd2be6..a08617b 100644 --- a/internal/handler/budget.go +++ b/internal/handler/budget.go @@ -37,16 +37,12 @@ func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) { 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 { + var req model.UpdateBudgetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } - budget, err := h.svc.Update(r.Context(), id, body.Amount, body.Notes, body.ChangedBy) + budget, err := h.svc.Update(r.Context(), id, req) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/model/model.go b/internal/model/model.go index c417e5a..1a05687 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -62,6 +62,14 @@ type CreateBudgetRequest struct { CreatedBy string `json:"created_by"` } +type UpdateBudgetRequest struct { + ID int `json:"id"` + Amount float64 `json:"amount"` + Notes string `json:"notes"` + Version BudgetVersion `json:"version"` + ChangedBy string `json:"created_by"` +} + type Actual struct { ID int `json:"id"` FiscalYear int `json:"fiscal_year"` diff --git a/internal/service/bugdet-service.go b/internal/service/bugdet-service.go index a0e320e..6a41cd1 100644 --- a/internal/service/bugdet-service.go +++ b/internal/service/bugdet-service.go @@ -19,8 +19,8 @@ func (s *BudgetService) Create(ctx context.Context, req model.CreateBudgetReques 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) Update(ctx context.Context, id int, req model.UpdateBudgetRequest) (*model.Budget, error) { + return s.repo.Update(ctx, id, req) } func (s *BudgetService) Delete(ctx context.Context, id int) error {