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) { // ON CONFLICT upsert: if the unique key (year, period, version, dept, gl) already // exists, update the mutable fields instead of failing. // Makes the endpoint idempotent — safe for repeated test runs and re-imports. 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 (?,?,?,?,?,?,?,?,?) ON CONFLICT(fiscal_year, fiscal_period, version, department_id, gl_account_id) DO UPDATE SET amount = excluded.amount, currency = excluded.currency, notes = excluded.notes, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')`, 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) } // LastInsertId returns the existing row id on a conflict branch in SQLite. 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, req model.UpdateBudgetRequest) (*model.Budget, error) { q := `UPDATE budgets SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')` args := []any{} if req.Amount != nil { q += `, amount = ?` args = append(args, *req.Amount) } if req.Notes != nil { q += `, notes = ?` args = append(args, *req.Notes) } if req.Version != nil { q += `, version = ?` args = append(args, *req.Version) } if req.Currency != nil { q += `, currency = ?` args = append(args, *req.Currency) } if req.FiscalYear != nil { q += `, fiscal_year = ?` args = append(args, *req.FiscalYear) } if req.FiscalPeriod != nil { q += `, fiscal_period = ?` args = append(args, *req.FiscalPeriod) } if req.DepartmentID != nil { q += `, department_id = ?` args = append(args, *req.DepartmentID) } if req.GLAccountID != nil { q += `, gl_account_id = ?` args = append(args, *req.GLAccountID) } q += ` WHERE id = ?` args = append(args, req.ID) if _, err := r.db.ExecContext(ctx, q, args...); err != nil { return nil, fmt.Errorf("update budget: %w", err) } row := r.db.QueryRowContext(ctx, `SELECT `+budgetSelectCols+` FROM budgets WHERE id = ?`, req.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, req model.DeleteBudgetRequest) error { _, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id = ?`, req.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() } func (r *BudgetRepo) DB() *sql.DB { return r.db }