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 }