Database: time_entries, billing_rates, invoices tables with RLS.
Backend: CRUD services+handlers for time entries, billing rates, invoices.
- Time entries: list/create/update/delete, summary by case/user/month
- Billing rates: upsert with auto-close previous, current rate lookup
- Invoices: create with auto-number (RE-YYYY-NNN), status transitions
(draft->sent->paid, cancellation), link time entries on invoice create
API: 11 new endpoints under /api/time-entries, /api/billing-rates, /api/invoices
Frontend: Zeiterfassung tab on case detail, /abrechnung overview with filters,
/abrechnung/rechnungen list+detail with status actions, billing rates settings
Also: resolved merge conflicts between audit-trail and role-based branches,
added missing types (Notification, AuditLogResponse, NotificationPreferences)
277 lines
8.0 KiB
Go
277 lines
8.0 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
|
)
|
|
|
|
type TimeEntryService struct {
|
|
db *sqlx.DB
|
|
audit *AuditService
|
|
}
|
|
|
|
func NewTimeEntryService(db *sqlx.DB, audit *AuditService) *TimeEntryService {
|
|
return &TimeEntryService{db: db, audit: audit}
|
|
}
|
|
|
|
type CreateTimeEntryInput struct {
|
|
CaseID uuid.UUID `json:"case_id"`
|
|
Date string `json:"date"`
|
|
DurationMinutes int `json:"duration_minutes"`
|
|
Description string `json:"description"`
|
|
Activity *string `json:"activity,omitempty"`
|
|
Billable *bool `json:"billable,omitempty"`
|
|
HourlyRate *float64 `json:"hourly_rate,omitempty"`
|
|
}
|
|
|
|
type UpdateTimeEntryInput struct {
|
|
Date *string `json:"date,omitempty"`
|
|
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Activity *string `json:"activity,omitempty"`
|
|
Billable *bool `json:"billable,omitempty"`
|
|
HourlyRate *float64 `json:"hourly_rate,omitempty"`
|
|
}
|
|
|
|
type TimeEntryFilter struct {
|
|
CaseID *uuid.UUID
|
|
UserID *uuid.UUID
|
|
From string
|
|
To string
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
type TimeEntrySummary struct {
|
|
GroupKey string `db:"group_key" json:"group_key"`
|
|
TotalMinutes int `db:"total_minutes" json:"total_minutes"`
|
|
BillableMinutes int `db:"billable_minutes" json:"billable_minutes"`
|
|
TotalAmount float64 `db:"total_amount" json:"total_amount"`
|
|
EntryCount int `db:"entry_count" json:"entry_count"`
|
|
}
|
|
|
|
const timeEntryCols = `id, tenant_id, case_id, user_id, date, duration_minutes, description,
|
|
activity, billable, billed, invoice_id, hourly_rate, created_at, updated_at`
|
|
|
|
func (s *TimeEntryService) ListForCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.TimeEntry, error) {
|
|
var entries []models.TimeEntry
|
|
err := s.db.SelectContext(ctx, &entries,
|
|
`SELECT `+timeEntryCols+` FROM time_entries
|
|
WHERE tenant_id = $1 AND case_id = $2
|
|
ORDER BY date DESC, created_at DESC`,
|
|
tenantID, caseID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list time entries for case: %w", err)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (s *TimeEntryService) List(ctx context.Context, tenantID uuid.UUID, filter TimeEntryFilter) ([]models.TimeEntry, int, error) {
|
|
if filter.Limit <= 0 {
|
|
filter.Limit = 20
|
|
}
|
|
if filter.Limit > 100 {
|
|
filter.Limit = 100
|
|
}
|
|
|
|
where := "WHERE tenant_id = $1"
|
|
args := []any{tenantID}
|
|
argIdx := 2
|
|
|
|
if filter.CaseID != nil {
|
|
where += fmt.Sprintf(" AND case_id = $%d", argIdx)
|
|
args = append(args, *filter.CaseID)
|
|
argIdx++
|
|
}
|
|
if filter.UserID != nil {
|
|
where += fmt.Sprintf(" AND user_id = $%d", argIdx)
|
|
args = append(args, *filter.UserID)
|
|
argIdx++
|
|
}
|
|
if filter.From != "" {
|
|
where += fmt.Sprintf(" AND date >= $%d", argIdx)
|
|
args = append(args, filter.From)
|
|
argIdx++
|
|
}
|
|
if filter.To != "" {
|
|
where += fmt.Sprintf(" AND date <= $%d", argIdx)
|
|
args = append(args, filter.To)
|
|
argIdx++
|
|
}
|
|
|
|
var total int
|
|
err := s.db.GetContext(ctx, &total,
|
|
"SELECT COUNT(*) FROM time_entries "+where, args...)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("count time entries: %w", err)
|
|
}
|
|
|
|
query := fmt.Sprintf("SELECT %s FROM time_entries %s ORDER BY date DESC, created_at DESC LIMIT $%d OFFSET $%d",
|
|
timeEntryCols, where, argIdx, argIdx+1)
|
|
args = append(args, filter.Limit, filter.Offset)
|
|
|
|
var entries []models.TimeEntry
|
|
err = s.db.SelectContext(ctx, &entries, query, args...)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("list time entries: %w", err)
|
|
}
|
|
return entries, total, nil
|
|
}
|
|
|
|
func (s *TimeEntryService) GetByID(ctx context.Context, tenantID, entryID uuid.UUID) (*models.TimeEntry, error) {
|
|
var entry models.TimeEntry
|
|
err := s.db.GetContext(ctx, &entry,
|
|
`SELECT `+timeEntryCols+` FROM time_entries WHERE tenant_id = $1 AND id = $2`,
|
|
tenantID, entryID)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get time entry: %w", err)
|
|
}
|
|
return &entry, nil
|
|
}
|
|
|
|
func (s *TimeEntryService) Create(ctx context.Context, tenantID, userID uuid.UUID, input CreateTimeEntryInput) (*models.TimeEntry, error) {
|
|
billable := true
|
|
if input.Billable != nil {
|
|
billable = *input.Billable
|
|
}
|
|
|
|
// If no hourly rate provided, look up the current billing rate
|
|
hourlyRate := input.HourlyRate
|
|
if hourlyRate == nil {
|
|
var rate float64
|
|
err := s.db.GetContext(ctx, &rate,
|
|
`SELECT rate FROM billing_rates
|
|
WHERE tenant_id = $1 AND (user_id = $2 OR user_id IS NULL)
|
|
AND valid_from <= $3 AND (valid_to IS NULL OR valid_to >= $3)
|
|
ORDER BY user_id NULLS LAST LIMIT 1`,
|
|
tenantID, userID, input.Date)
|
|
if err == nil {
|
|
hourlyRate = &rate
|
|
}
|
|
}
|
|
|
|
var entry models.TimeEntry
|
|
err := s.db.QueryRowxContext(ctx,
|
|
`INSERT INTO time_entries (tenant_id, case_id, user_id, date, duration_minutes, description, activity, billable, hourly_rate)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING `+timeEntryCols,
|
|
tenantID, input.CaseID, userID, input.Date, input.DurationMinutes, input.Description, input.Activity, billable, hourlyRate,
|
|
).StructScan(&entry)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create time entry: %w", err)
|
|
}
|
|
|
|
s.audit.Log(ctx, "create", "time_entry", &entry.ID, nil, entry)
|
|
return &entry, nil
|
|
}
|
|
|
|
func (s *TimeEntryService) Update(ctx context.Context, tenantID, entryID uuid.UUID, input UpdateTimeEntryInput) (*models.TimeEntry, error) {
|
|
old, err := s.GetByID(ctx, tenantID, entryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if old == nil {
|
|
return nil, fmt.Errorf("time entry not found")
|
|
}
|
|
if old.Billed {
|
|
return nil, fmt.Errorf("cannot update a billed time entry")
|
|
}
|
|
|
|
var entry models.TimeEntry
|
|
err = s.db.QueryRowxContext(ctx,
|
|
`UPDATE time_entries SET
|
|
date = COALESCE($3, date),
|
|
duration_minutes = COALESCE($4, duration_minutes),
|
|
description = COALESCE($5, description),
|
|
activity = COALESCE($6, activity),
|
|
billable = COALESCE($7, billable),
|
|
hourly_rate = COALESCE($8, hourly_rate),
|
|
updated_at = now()
|
|
WHERE tenant_id = $1 AND id = $2
|
|
RETURNING `+timeEntryCols,
|
|
tenantID, entryID, input.Date, input.DurationMinutes, input.Description, input.Activity, input.Billable, input.HourlyRate,
|
|
).StructScan(&entry)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update time entry: %w", err)
|
|
}
|
|
|
|
s.audit.Log(ctx, "update", "time_entry", &entry.ID, old, entry)
|
|
return &entry, nil
|
|
}
|
|
|
|
func (s *TimeEntryService) Delete(ctx context.Context, tenantID, entryID uuid.UUID) error {
|
|
old, err := s.GetByID(ctx, tenantID, entryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if old == nil {
|
|
return fmt.Errorf("time entry not found")
|
|
}
|
|
if old.Billed {
|
|
return fmt.Errorf("cannot delete a billed time entry")
|
|
}
|
|
|
|
_, err = s.db.ExecContext(ctx,
|
|
`DELETE FROM time_entries WHERE tenant_id = $1 AND id = $2`,
|
|
tenantID, entryID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete time entry: %w", err)
|
|
}
|
|
|
|
s.audit.Log(ctx, "delete", "time_entry", &entryID, old, nil)
|
|
return nil
|
|
}
|
|
|
|
func (s *TimeEntryService) Summary(ctx context.Context, tenantID uuid.UUID, groupBy string, from, to string) ([]TimeEntrySummary, error) {
|
|
var groupExpr string
|
|
switch groupBy {
|
|
case "user":
|
|
groupExpr = "user_id::text"
|
|
case "month":
|
|
groupExpr = "to_char(date, 'YYYY-MM')"
|
|
default:
|
|
groupExpr = "case_id::text"
|
|
}
|
|
|
|
where := "WHERE tenant_id = $1"
|
|
args := []any{tenantID}
|
|
argIdx := 2
|
|
|
|
if from != "" {
|
|
where += fmt.Sprintf(" AND date >= $%d", argIdx)
|
|
args = append(args, from)
|
|
argIdx++
|
|
}
|
|
if to != "" {
|
|
where += fmt.Sprintf(" AND date <= $%d", argIdx)
|
|
args = append(args, to)
|
|
argIdx++
|
|
}
|
|
|
|
query := fmt.Sprintf(`SELECT %s AS group_key,
|
|
SUM(duration_minutes) AS total_minutes,
|
|
SUM(CASE WHEN billable THEN duration_minutes ELSE 0 END) AS billable_minutes,
|
|
SUM(CASE WHEN billable AND hourly_rate IS NOT NULL THEN duration_minutes * hourly_rate / 60.0 ELSE 0 END) AS total_amount,
|
|
COUNT(*) AS entry_count
|
|
FROM time_entries %s
|
|
GROUP BY %s
|
|
ORDER BY %s`,
|
|
groupExpr, where, groupExpr, groupExpr)
|
|
|
|
var summaries []TimeEntrySummary
|
|
err := s.db.SelectContext(ctx, &summaries, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("time entry summary: %w", err)
|
|
}
|
|
return summaries, nil
|
|
}
|