361 lines
10 KiB
Go

package models
import (
"context"
"errors"
"fmt"
"time"
"github.com/oklog/ulid/v2"
"gorm.io/gorm"
)
// TimeEntry repräsentiert einen Zeiteintrag im System
type TimeEntry struct {
EntityBase
UserID ulid.ULID `gorm:"column:user_id;type:uuid;not null;index"`
ProjectID ulid.ULID `gorm:"column:project_id;type:uuid;not null;index"`
ActivityID ulid.ULID `gorm:"column:activity_id;type:uuid;not null;index"`
Start time.Time `gorm:"column:start;not null"`
End time.Time `gorm:"column:end;not null"`
Description string `gorm:"column:description"`
Billable int `gorm:"column:billable"` // Percentage (0-100)
// Beziehungen für Eager Loading
User *User `gorm:"foreignKey:UserID"`
Project *Project `gorm:"foreignKey:ProjectID"`
Activity *Activity `gorm:"foreignKey:ActivityID"`
}
// TableName gibt den Tabellennamen für GORM an
func (TimeEntry) TableName() string {
return "time_entries"
}
// TimeEntryCreate enthält die Felder zum Erstellen eines neuen Zeiteintrags
type TimeEntryCreate struct {
UserID ulid.ULID
ProjectID ulid.ULID
ActivityID ulid.ULID
Start time.Time
End time.Time
Description string
Billable int // Percentage (0-100)
}
// TimeEntryUpdate enthält die aktualisierbaren Felder eines Zeiteintrags
type TimeEntryUpdate struct {
ID ulid.ULID `gorm:"-"` // Ausschließen von Updates
UserID *ulid.ULID `gorm:"column:user_id"`
ProjectID *ulid.ULID `gorm:"column:project_id"`
ActivityID *ulid.ULID `gorm:"column:activity_id"`
Start *time.Time `gorm:"column:start"`
End *time.Time `gorm:"column:end"`
Description *string `gorm:"column:description"`
Billable *int `gorm:"column:billable"`
}
// Validate prüft, ob die Create-Struktur gültige Daten enthält
func (tc *TimeEntryCreate) Validate() error {
// Prüfung auf leere IDs
if tc.UserID.Compare(ulid.ULID{}) == 0 {
return errors.New("userID darf nicht leer sein")
}
if tc.ProjectID.Compare(ulid.ULID{}) == 0 {
return errors.New("projectID darf nicht leer sein")
}
if tc.ActivityID.Compare(ulid.ULID{}) == 0 {
return errors.New("activityID darf nicht leer sein")
}
// Zeitprüfungen
if tc.Start.IsZero() {
return errors.New("startzeit darf nicht leer sein")
}
if tc.End.IsZero() {
return errors.New("endzeit darf nicht leer sein")
}
if tc.End.Before(tc.Start) {
return errors.New("endzeit kann nicht vor der startzeit liegen")
}
// Billable-Prozent Prüfung
if tc.Billable < 0 || tc.Billable > 100 {
return errors.New("billable muss zwischen 0 und 100 liegen")
}
return nil
}
// Validate prüft, ob die Update-Struktur gültige Daten enthält
func (tu *TimeEntryUpdate) Validate() error {
// Billable-Prozent Prüfung
if tu.Billable != nil && (*tu.Billable < 0 || *tu.Billable > 100) {
return errors.New("billable muss zwischen 0 und 100 liegen")
}
// Zeitprüfungen
if tu.Start != nil && tu.End != nil && tu.End.Before(*tu.Start) {
return errors.New("endzeit kann nicht vor der startzeit liegen")
}
return nil
}
// GetTimeEntryByID sucht einen Zeiteintrag anhand seiner ID
func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
var timeEntry TimeEntry
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, result.Error
}
return &timeEntry, nil
}
// GetTimeEntryWithRelations lädt einen Zeiteintrag mit allen zugehörigen Daten
func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
var timeEntry TimeEntry
result := GetEngine(ctx).
Preload("User").
Preload("Project").
Preload("Project.Customer"). // Verschachtelte Beziehung
Preload("Activity").
Where("id = ?", id).
First(&timeEntry)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, result.Error
}
return &timeEntry, nil
}
// GetAllTimeEntries gibt alle Zeiteinträge zurück
func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
var timeEntries []TimeEntry
result := GetEngine(ctx).Find(&timeEntries)
if result.Error != nil {
return nil, result.Error
}
return timeEntries, nil
}
// GetTimeEntriesByUserID gibt alle Zeiteinträge eines Benutzers zurück
func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry, error) {
var timeEntries []TimeEntry
result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
if result.Error != nil {
return nil, result.Error
}
return timeEntries, nil
}
// GetTimeEntriesByProjectID gibt alle Zeiteinträge eines Projekts zurück
func GetTimeEntriesByProjectID(ctx context.Context, projectID ulid.ULID) ([]TimeEntry, error) {
var timeEntries []TimeEntry
result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
if result.Error != nil {
return nil, result.Error
}
return timeEntries, nil
}
// GetTimeEntriesByDateRange gibt alle Zeiteinträge in einem Zeitraum zurück
func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
var timeEntries []TimeEntry
// Suche nach Überschneidungen im Zeitraum
result := GetEngine(ctx).
Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)",
start, end, start, end).
Find(&timeEntries)
if result.Error != nil {
return nil, result.Error
}
return timeEntries, nil
}
// SumBillableHoursByProject berechnet die abrechenbaren Stunden pro Projekt
func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float64, error) {
type Result struct {
TotalHours float64
}
var result Result
// SQL-Berechnung der gewichteten Stunden
err := GetEngine(ctx).Raw(`
SELECT SUM(
EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0)
) as total_hours
FROM time_entries
WHERE project_id = ?
`, projectID).Scan(&result).Error
if err != nil {
return 0, err
}
return result.TotalHours, nil
}
// CreateTimeEntry erstellt einen neuen Zeiteintrag mit Validierung
func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, error) {
// Validierung
if err := create.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err)
}
// Starten einer Transaktion
var timeEntry *TimeEntry
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Verweise prüfen
if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil {
return err
}
// Zeiteintrag erstellen
newTimeEntry := TimeEntry{
UserID: create.UserID,
ProjectID: create.ProjectID,
ActivityID: create.ActivityID,
Start: create.Start,
End: create.End,
Description: create.Description,
Billable: create.Billable,
}
if err := tx.Create(&newTimeEntry).Error; err != nil {
return fmt.Errorf("fehler beim Erstellen des Zeiteintrags: %w", err)
}
timeEntry = &newTimeEntry
return nil
})
if err != nil {
return nil, err
}
return timeEntry, nil
}
// validateReferences prüft, ob alle referenzierten Entitäten existieren
func validateReferences(tx *gorm.DB, userID, projectID, activityID ulid.ULID) error {
// Benutzer prüfen
var userCount int64
if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen des Benutzers: %w", err)
}
if userCount == 0 {
return errors.New("der angegebene Benutzer existiert nicht")
}
// Projekt prüfen
var projectCount int64
if err := tx.Model(&Project{}).Where("id = ?", projectID).Count(&projectCount).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen des Projekts: %w", err)
}
if projectCount == 0 {
return errors.New("das angegebene Projekt existiert nicht")
}
// Aktivität prüfen
var activityCount int64
if err := tx.Model(&Activity{}).Where("id = ?", activityID).Count(&activityCount).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen der Aktivität: %w", err)
}
if activityCount == 0 {
return errors.New("die angegebene Aktivität existiert nicht")
}
return nil
}
// UpdateTimeEntry aktualisiert einen bestehenden Zeiteintrag mit Validierung
func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, error) {
// Validierung
if err := update.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err)
}
// Zeiteintrag suchen
timeEntry, err := GetTimeEntryByID(ctx, update.ID)
if err != nil {
return nil, err
}
if timeEntry == nil {
return nil, errors.New("zeiteintrag nicht gefunden")
}
// Starten einer Transaktion für das Update
err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Referenzen prüfen, falls sie aktualisiert werden
if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil {
// Aktuelle Werte verwenden, wenn nicht aktualisiert
userID := timeEntry.UserID
if update.UserID != nil {
userID = *update.UserID
}
projectID := timeEntry.ProjectID
if update.ProjectID != nil {
projectID = *update.ProjectID
}
activityID := timeEntry.ActivityID
if update.ActivityID != nil {
activityID = *update.ActivityID
}
if err := validateReferences(tx, userID, projectID, activityID); err != nil {
return err
}
}
// Zeitkonsistenz prüfen
start := timeEntry.Start
if update.Start != nil {
start = *update.Start
}
end := timeEntry.End
if update.End != nil {
end = *update.End
}
if end.Before(start) {
return errors.New("endzeit kann nicht vor der startzeit liegen")
}
// Generisches Update verwenden
if err := UpdateModel(ctx, timeEntry, update); err != nil {
return fmt.Errorf("fehler beim Aktualisieren des Zeiteintrags: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
// Aktualisierte Daten aus der Datenbank laden
return GetTimeEntryByID(ctx, update.ID)
}
// DeleteTimeEntry löscht einen Zeiteintrag anhand seiner ID
func DeleteTimeEntry(ctx context.Context, id ulid.ULID) error {
result := GetEngine(ctx).Delete(&TimeEntry{}, id)
if result.Error != nil {
return fmt.Errorf("fehler beim Löschen des Zeiteintrags: %w", result.Error)
}
return nil
}