db fix and new update request
This commit is contained in:
@@ -2,16 +2,17 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"Engine/internal/model"
|
"Engine/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActualsRepo struct {
|
type ActualsRepo struct {
|
||||||
db *DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewActualsRepo(db *DB) *ActualsRepo {
|
func NewActualsRepo(db *sql.DB) *ActualsRepo {
|
||||||
return &ActualsRepo{db: db}
|
return &ActualsRepo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,78 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"Engine/internal/model"
|
"Engine/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BudgetRepo struct {
|
// SQLite stores timestamps as TEXT in the format written by
|
||||||
db *DB
|
// 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}
|
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) {
|
func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest) (*model.Budget, error) {
|
||||||
res, err := r.db.ExecContext(ctx, `
|
res, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO budgets
|
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 (?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
req.FiscalYear, req.FiscalPeriod, req.Version,
|
req.FiscalYear, req.FiscalPeriod, req.Version,
|
||||||
req.DepartmentID, req.GLAccountID, req.Amount,
|
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)
|
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{}
|
row := r.db.QueryRowContext(ctx,
|
||||||
err = r.db.QueryRowContext(ctx, `
|
`SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id)
|
||||||
SELECT id, fiscal_year, fiscal_period, version, department_id, gl_account_id,
|
b, err := scanBudget(row)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch created budget: %w", err)
|
return nil, fmt.Errorf("fetch created budget: %w", err)
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BudgetRepo) List(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.BudgetRow, error) {
|
func (r *BudgetRepo) Update(ctx context.Context, id int, req model.UpdateBudgetRequest) (*model.Budget, 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, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE budgets
|
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=?`,
|
WHERE id=?`,
|
||||||
amount, notes, id,
|
req.Version, req.Amount, req.Notes, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update budget %d: %w", id, err)
|
return nil, fmt.Errorf("update budget: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b := &model.Budget{}
|
row := r.db.QueryRowContext(ctx,
|
||||||
err = r.db.QueryRowContext(ctx, `
|
`SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id)
|
||||||
SELECT id, fiscal_year, fiscal_period, version, department_id, gl_account_id,
|
b, err := scanBudget(row)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch updated budget: %w", err)
|
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 {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("delete budget: %w", err)
|
return fmt.Errorf("delete budget: %w", err)
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return fmt.Errorf("budget %d not found", id)
|
|
||||||
}
|
|
||||||
return nil
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type DB struct {
|
|||||||
|
|
||||||
// Connect opens (or creates) the SQLite file at path and runs pragmas for
|
// Connect opens (or creates) the SQLite file at path and runs pragmas for
|
||||||
// safe, performant operation. Pass ":memory:" for tests.
|
// 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)
|
db, err := sql.Open("sqlite", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
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 nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DB{db}, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
// Migrate runs the schema bootstrap. SQLite doesn't need a migration tool —
|
// 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.
|
// 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{
|
stmts := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS departments (
|
`CREATE TABLE IF NOT EXISTS departments (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,10 +29,10 @@ type GLAccount struct {
|
|||||||
// ── ReferenceRepo ─────────────────────────────────────────────────────────────
|
// ── ReferenceRepo ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ReferenceRepo struct {
|
type ReferenceRepo struct {
|
||||||
db *DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReferenceRepo(db *DB) *ReferenceRepo {
|
func NewReferenceRepo(db *sql.DB) *ReferenceRepo {
|
||||||
return &ReferenceRepo{db: db}
|
return &ReferenceRepo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,16 +37,12 @@ func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid id")
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var req model.UpdateBudgetRequest
|
||||||
Amount float64 `json:"amount"`
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
Notes string `json:"notes"`
|
|
||||||
ChangedBy string `json:"changed_by"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ type CreateBudgetRequest struct {
|
|||||||
CreatedBy string `json:"created_by"`
|
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 {
|
type Actual struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
FiscalYear int `json:"fiscal_year"`
|
FiscalYear int `json:"fiscal_year"`
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ func (s *BudgetService) Create(ctx context.Context, req model.CreateBudgetReques
|
|||||||
return s.repo.Create(ctx, req)
|
return s.repo.Create(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BudgetService) Update(ctx context.Context, id int, amount float64, notes, changedBy string) (*model.Budget, error) {
|
func (s *BudgetService) Update(ctx context.Context, id int, req model.UpdateBudgetRequest) (*model.Budget, error) {
|
||||||
return s.repo.Update(ctx, id, amount, notes, changedBy)
|
return s.repo.Update(ctx, id, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BudgetService) Delete(ctx context.Context, id int) error {
|
func (s *BudgetService) Delete(ctx context.Context, id int) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user