258 lines
7.1 KiB
Go
258 lines
7.1 KiB
Go
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 }
|