361 lines
10 KiB
Go
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
|
|
}
|