Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
package store
import (
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)
// OpenDB stellt Migrationsmechanik und Zeitformat auch anderen Datenbanken
// bereit (z. B. accountant.db).
func TestOpenDB_Migrations(t *testing.T) {
path := filepath.Join(t.TempDir(), "x.db")
v1 := []string{`CREATE TABLE a (id TEXT PRIMARY KEY, at DATETIME NOT NULL);`}
db, err := OpenDB(ctx, path, v1)
if err != nil {
t.Fatal(err)
}
at := time.Date(2026, 10, 16, 8, 15, 30, 5, time.FixedZone("CEST", 2*3600))
if _, err := db.ExecContext(ctx, `INSERT INTO a VALUES ('x', ?)`, FormatTime(at)); err != nil {
t.Fatal(err)
}
var got time.Time
if err := db.QueryRowContext(ctx, `SELECT at FROM a`).Scan(ScanTime(&got)); err != nil {
t.Fatal(err)
}
if !got.Equal(at) || got.Location() != time.UTC {
t.Errorf("Zeit = %v, erwartet %v in UTC", got, at)
}
var null time.Time
if err := db.QueryRowContext(ctx, `SELECT NULL`).Scan(ScanTime(&null)); err != nil || !null.IsZero() {
t.Errorf("NULL = %v, %v", null, err)
}
db.Close()
// Neue Migration wird angehängt, bestehende Daten bleiben.
v2 := append(v1, `ALTER TABLE a ADD COLUMN note TEXT NOT NULL DEFAULT '';`)
db, err = OpenDB(ctx, path, v2)
if err != nil {
t.Fatal(err)
}
var n int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM a WHERE note = ''`).Scan(&n); err != nil || n != 1 {
t.Errorf("nach Migration: n=%d err=%v", n, err)
}
db.Close()
// Ältere Programmversion verweigert die neuere Datenbank.
if _, err := OpenDB(ctx, path, v1); !errors.Is(err, ErrSchemaTooNew) {
t.Errorf("ältere Version: %v, erwartet ErrSchemaTooNew", err)
}
}
var _ sql.Scanner = ScanTime(nil)
+30 -7
View File
@@ -111,6 +111,19 @@ type Store struct {
// Open öffnet (bzw. erstellt) die Datenbank unter path und bringt das Schema
// auf den aktuellen Stand.
func Open(ctx context.Context, path string) (*Store, error) {
db, err := OpenDB(ctx, path, migrations)
if err != nil {
return nil, err
}
return &Store{db: db, now: time.Now}, nil
}
// OpenDB öffnet (bzw. erstellt) eine SQLite-Datenbank mit den Einstellungen
// des Servers und wendet migrations an: migrations[i] hebt das Schema von
// Version i auf i+1 (Version in PRAGMA user_version). Die Liste darf nur
// wachsen; eine neuere Datenbank liefert ErrSchemaTooNew. Für weitere
// Datenbanken wie accountant.db.
func OpenDB(ctx context.Context, path string, migrations []string) (*sql.DB, error) {
// foreign_keys: sonst greift ON DELETE CASCADE nicht (gilt pro Verbindung).
// _txlock=immediate: Schreib-Transaktionen holen die Sperre sofort und
// warten per busy_timeout, statt beim Upgrade mit SQLITE_BUSY abzubrechen.
@@ -120,12 +133,11 @@ func Open(ctx context.Context, path string) (*Store, error) {
if err != nil {
return nil, err
}
s := &Store{db: db, now: time.Now}
if err := s.migrate(ctx); err != nil {
if err := migrate(ctx, db, migrations); err != nil {
db.Close()
return nil, err
}
return s, nil
return db, nil
}
// Close schließt die Datenbank.
@@ -133,13 +145,17 @@ func (s *Store) Close() error { return s.db.Close() }
// SchemaVersion liefert die aktuelle Schema-Version.
func (s *Store) SchemaVersion(ctx context.Context) (int, error) {
return schemaVersion(ctx, s.db)
}
func schemaVersion(ctx context.Context, db *sql.DB) (int, error) {
var v int
err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
return v, err
}
func (s *Store) migrate(ctx context.Context) error {
v, err := s.SchemaVersion(ctx)
func migrate(ctx context.Context, db *sql.DB, migrations []string) error {
v, err := schemaVersion(ctx, db)
if err != nil {
return fmt.Errorf("store: Schema-Version lesen: %w", err)
}
@@ -147,7 +163,7 @@ func (s *Store) migrate(ctx context.Context) error {
return fmt.Errorf("%w (Datenbank v%d, bekannt bis v%d)", ErrSchemaTooNew, v, len(migrations))
}
for i := v; i < len(migrations); i++ {
tx, err := s.db.BeginTx(ctx, nil)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
@@ -172,6 +188,13 @@ const timeLayout = "2006-01-02T15:04:05.000000000Z"
func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) }
// FormatTime formatiert t im Speicherformat (UTC, feste Breite) – für
// Datenbanken, die OpenDB nutzen.
func FormatTime(t time.Time) string { return formatTime(t) }
// ScanTime liest einen so gespeicherten Zeitstempel nach t (NULL = Nullzeit).
func ScanTime(t *time.Time) sql.Scanner { return dbTime{t} }
// normTime entfernt Monotonic-Clock und Zeitzone, damit gespeicherte und
// gelesene Werte identisch sind.
func normTime(t time.Time) time.Time { return t.Round(0).UTC() }