package database import ( "context" "database/sql" "fmt" "time" "Engine/internal/model" ) // 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 } // 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) 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, err := res.LastInsertId() if err != nil { return nil, fmt.Errorf("last insert id: %w", err) } 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) Update(ctx context.Context, id int, req model.UpdateBudgetRequest) (*model.Budget, error) { _, err := r.db.ExecContext(ctx, ` UPDATE budgets SET version=?, amount=?, notes=?, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?`, req.Version, req.Amount, req.Notes, id, ) if err != nil { return nil, fmt.Errorf("update budget: %w", err) } 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) } return b, nil } func (r *BudgetRepo) Delete(ctx context.Context, id int) error { _, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete budget: %w", err) } 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() }