Files
fm_be/internal/shared/pkg/util/shift_clock.go
2026-07-16 22:16:45 +07:00

245 lines
6.6 KiB
Go

package util
import (
"errors"
"fmt"
"math"
"strings"
"time"
basedomain "wucher/internal/domain/base"
"wucher/internal/shared/pkg/suncalc"
)
var (
ErrShiftScheduleMissing = errors.New("base shift schedule not found")
ErrShiftTwilightMissing = errors.New("base twilight time not available")
ErrShiftCoordinatesInvalid = errors.New("base coordinates invalid")
)
func ResolveShiftWindow(b *basedomain.Base, date time.Time) (string, string, error) {
if b == nil || date.IsZero() {
return "", "", ErrShiftScheduleMissing
}
shift := PickShiftForDate(b, date)
loc := LocationFromUTCOffset(b.UTC)
scheduleDay := calendarDate(date)
scheduleDate := time.Date(scheduleDay.Year(), scheduleDay.Month(), scheduleDay.Day(), 12, 0, 0, 0, loc)
if shift == nil {
if shiftWindowNeedsCoordinates(b.DefaultStartTimeType, b.DefaultEndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) {
return "", "", ErrShiftCoordinatesInvalid
}
return resolveBaseDefaultShiftWindow(b, scheduleDate, loc)
}
if shiftWindowNeedsCoordinates(shift.StartTimeType, shift.EndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) {
return "", "", ErrShiftCoordinatesInvalid
}
start, err := resolveShiftClock(scheduleDate, loc, b, shift.StartTimeType, shift.ShiftStart, true)
if err != nil {
return "", "", err
}
end, err := resolveShiftClock(scheduleDate, loc, b, shift.EndTimeType, shift.ShiftEnd, false)
if err != nil {
return "", "", err
}
return start, end, nil
}
func shiftWindowNeedsCoordinates(startTimeType, endTimeType string) bool {
return NormalizeShiftTimeType(startTimeType) != basedomain.ShiftTimeTypeFixed ||
NormalizeShiftTimeType(endTimeType) != basedomain.ShiftTimeTypeFixed
}
func resolveBaseDefaultShiftWindow(b *basedomain.Base, scheduleDate time.Time, loc *time.Location) (string, string, error) {
start, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultStartTimeType, b.DefaultShiftStart, true)
if err != nil {
return "", "", err
}
end, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultEndTimeType, b.DefaultShiftEnd, false)
if err != nil {
return "", "", err
}
if start == "" || end == "" {
return "", "", ErrShiftScheduleMissing
}
return start, end, nil
}
func PickShiftForDate(b *basedomain.Base, date time.Time) *basedomain.BaseOperationalShiftTime {
if b == nil {
return nil
}
day := calendarDate(date)
for i := range b.OperationalShiftTimes {
row := &b.OperationalShiftTimes[i]
if row.DateStart == nil || row.DateEnd == nil {
continue
}
start := calendarDate(*row.DateStart)
end := calendarDate(*row.DateEnd)
if (day.Equal(start) || day.After(start)) && (day.Equal(end) || day.Before(end.Add(24*time.Hour))) {
return row
}
}
return nil
}
func calendarDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
func DefaultShiftWindow(b *basedomain.Base) (string, string, error) {
if b == nil {
return "", "", ErrShiftScheduleMissing
}
start := NormalizeClockValue(b.DefaultShiftStart)
end := NormalizeClockValue(b.DefaultShiftEnd)
if start == "" || end == "" {
return "", "", ErrShiftScheduleMissing
}
return start, end, nil
}
func resolveShiftClock(date time.Time, loc *time.Location, b *basedomain.Base, timeType, fixedValue string, morning bool) (string, error) {
switch NormalizeShiftTimeType(timeType) {
case basedomain.ShiftTimeTypeFixed:
clock := NormalizeClockValue(fixedValue)
if clock == "" {
return "", ErrShiftScheduleMissing
}
return clock, nil
case basedomain.ShiftTimeTypeBMCT:
return resolveSunCalcClock(date, loc, b, true)
case basedomain.ShiftTimeTypeECET:
return resolveSunCalcClock(date, loc, b, false)
default:
return "", fmt.Errorf("invalid shift time type %q", timeType)
}
}
func resolveSunCalcClock(date time.Time, loc *time.Location, b *basedomain.Base, morning bool) (string, error) {
twilight, err := suncalc.GetCivilTwilight(date, b.Latitude, b.Longitude)
if err != nil {
return "", ErrShiftTwilightMissing
}
var resolved time.Time
if morning {
resolved = twilight.Dawn
} else {
resolved = twilight.Dusk
}
if resolved.IsZero() {
return "", ErrShiftTwilightMissing
}
if loc == nil {
loc = time.UTC
}
return resolved.In(loc).Format("15:04"), nil
}
func NormalizeClockValue(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if len(trimmed) == 5 {
trimmed += ":00"
}
layouts := []string{
"15:04:05",
"15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
time.RFC3339,
time.RFC3339Nano,
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, trimmed)
if err == nil {
return parsed.UTC().Format("15:04")
}
}
return ""
}
func LocationFromUTCOffset(raw string) *time.Location {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || trimmed == "0" {
return time.UTC
}
if loc, err := time.LoadLocation(trimmed); err == nil {
return loc
}
if offset, ok := parseUTCOffset(trimmed); ok {
return time.FixedZone("UTC", offset)
}
return time.UTC
}
func parseUTCOffset(raw string) (int, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0, false
}
sign := 1
switch {
case strings.HasPrefix(trimmed, "+"):
trimmed = strings.TrimPrefix(trimmed, "+")
case strings.HasPrefix(trimmed, "-"):
sign = -1
trimmed = strings.TrimPrefix(trimmed, "-")
}
trimmed = strings.TrimPrefix(strings.ToUpper(trimmed), "UTC")
trimmed = strings.TrimSpace(trimmed)
switch {
case strings.HasPrefix(trimmed, "+"):
trimmed = strings.TrimPrefix(trimmed, "+")
case strings.HasPrefix(trimmed, "-"):
sign = -1
trimmed = strings.TrimPrefix(trimmed, "-")
}
if trimmed == "" {
return 0, false
}
parts := strings.Split(trimmed, ":")
hours := 0
minutes := 0
switch len(parts) {
case 1:
if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil {
return 0, false
}
case 2:
if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil {
return 0, false
}
if _, err := fmt.Sscanf(parts[1], "%d", &minutes); err != nil {
return 0, false
}
default:
return 0, false
}
total := ((hours * 3600) + (minutes * 60)) * sign
return total, true
}
func ValidCoordinates(lat, lon float64) bool {
return !math.IsNaN(lat) && !math.IsNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
}
func NormalizeShiftTimeType(raw string) string {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "", basedomain.ShiftTimeTypeFixed:
return basedomain.ShiftTimeTypeFixed
case basedomain.ShiftTimeTypeBMCT:
return basedomain.ShiftTimeTypeBMCT
case basedomain.ShiftTimeTypeECET:
return basedomain.ShiftTimeTypeECET
default:
return strings.ToUpper(strings.TrimSpace(raw))
}
}