Files
fm_be/internal/service/duty_roster_service.go
2026-07-16 22:16:45 +07:00

1247 lines
36 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"time"
dutyroster "wucher/internal/domain/duty_roster"
"wucher/internal/shared/pkg/userctx"
"wucher/internal/shared/pkg/uuidv7"
requestdto "wucher/internal/transport/http/dto/request"
responsedto "wucher/internal/transport/http/dto/response"
)
type DutyRosterService struct {
repo dutyroster.Repository
}
type rangeAssignment struct {
crew dutyroster.DutyRosterCrew
start time.Time
end time.Time
}
type DutyRosterValidationError struct {
Status int
Title string
Pointer string
Detail string
}
func NewDutyRosterService(repo dutyroster.Repository) *DutyRosterService {
return &DutyRosterService{repo: repo}
}
func (s *DutyRosterService) Create(ctx context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error {
normalizeDutyRosterWriteInput(row, crews)
return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error {
if err := txRepo.CreateHeader(ctx, row); err != nil {
return err
}
insertRows := prepareCrewRowsForRosterInsert(row.ID, crews)
for i := range insertRows {
if err := txRepo.InsertCrew(ctx, &insertRows[i]); err != nil {
return err
}
}
return s.addRangeAssignments(ctx, txRepo, row, crews)
})
}
func (s *DutyRosterService) Update(ctx context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error {
normalizeDutyRosterWriteInput(row, crews)
return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error {
if _, err := txRepo.LockRosterByID(ctx, row.ID); err != nil {
return err
}
oldCrews := []dutyroster.DutyRosterCrew{}
if len(crews) > 0 {
var err error
oldCrews, err = txRepo.ListActiveCrewsByRosterID(ctx, row.ID)
if err != nil {
return err
}
}
if err := txRepo.UpdateHeader(ctx, row); err != nil {
return err
}
if len(crews) == 0 {
return nil
}
insertRows := prepareCrewRowsForRosterInsert(row.ID, crews)
replacedKeys := map[string]struct{}{}
for i := range insertRows {
replaceKey := replaceByUserKey(insertRows[i].RosterID, insertRows[i].RoleCode, insertRows[i].CrewType, insertRows[i].UserID)
if replaceKey != "" {
if _, ok := replacedKeys[replaceKey]; !ok {
if err := txRepo.SoftDeleteCrewByUser(ctx, insertRows[i].RosterID, insertRows[i].RoleCode, insertRows[i].CrewType, insertRows[i].UserID, row.UpdatedBy); err != nil {
return err
}
replacedKeys[replaceKey] = struct{}{}
}
}
exists, err := txRepo.CrewExists(ctx, insertRows[i])
if err != nil {
return err
}
if exists {
continue
}
if err := txRepo.InsertCrew(ctx, &insertRows[i]); err != nil {
return err
}
}
if err := s.addRangeAssignments(ctx, txRepo, row, crews); err != nil {
return err
}
return s.removeRangeAssignments(ctx, txRepo, row, oldCrews, crews)
})
}
func (s *DutyRosterService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error {
if _, err := txRepo.LockRosterByID(ctx, id); err != nil {
return err
}
if err := txRepo.SoftDeleteHeader(ctx, id, deletedBy); err != nil {
return err
}
return txRepo.SoftDeleteCrewsByRosterID(ctx, id, deletedBy)
})
}
func (s *DutyRosterService) HardDelete(ctx context.Context, id []byte) error {
return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error {
if err := txRepo.HardDeleteCrewsByRosterID(ctx, id); err != nil {
return err
}
return txRepo.HardDeleteHeader(ctx, id)
})
}
func (s *DutyRosterService) DeleteCrews(ctx context.Context, id []byte, crews []dutyroster.DutyRosterCrew, deletedBy []byte) ([]dutyroster.DeletedCrewResult, error) {
if len(crews) == 0 {
return []dutyroster.DeletedCrewResult{}, nil
}
normalizeDutyRosterFilterInput(crews)
collected := make(map[string]dutyroster.DeletedCrewResult)
err := s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error {
header, err := txRepo.LockRosterByID(ctx, id)
if err != nil {
return err
}
baseDate := dateOnlyUTC(header.DutyDate)
for i := range crews {
dates := []time.Time{baseDate}
if isRoleRangeAssignable(crews[i].RoleCode) && crews[i].DateStart != nil && crews[i].DateEnd != nil {
dates = expandDateRange(*crews[i].DateStart, *crews[i].DateEnd)
}
for _, d := range dates {
rosterIDs, err := txRepo.FindRosterIDsByBaseDate(ctx, header.BaseID, d)
if err != nil {
return err
}
if len(rosterIDs) == 0 {
continue
}
rows, err := txRepo.MatchCrewByFilter(ctx, rosterIDs, crews[i])
if err != nil {
return err
}
if len(rows) == 0 {
continue
}
for j := range rows {
k := fmt.Sprintf("%x", rows[j].ID)
if _, ok := collected[k]; !ok {
collected[k] = rows[j]
}
}
if err := txRepo.SoftDeleteCrewRows(ctx, rows, deletedBy); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return nil, err
}
out := make([]dutyroster.DeletedCrewResult, 0, len(collected))
for _, v := range collected {
out = append(out, v)
}
return out, nil
}
func (s *DutyRosterService) GetBaseDefaultShift(ctx context.Context, baseID []byte, baseType string) (string, error) {
return s.repo.GetBaseDefaultShift(ctx, baseID, baseType)
}
func (s *DutyRosterService) ResolveBaseDefaultShiftRange(ctx context.Context, baseID []byte, baseType string) (string, string, error) {
if len(baseID) == 0 {
return "", "", nil
}
raw, err := s.repo.GetBaseDefaultShift(ctx, baseID, baseType)
if err != nil {
return "", "", err
}
start, end := ParseDutyRosterShiftRange(raw)
return start, end, nil
}
func (s *DutyRosterService) GetHeaderByID(ctx context.Context, id []byte) (*dutyroster.HeaderView, error) {
return s.repo.GetHeaderByID(ctx, id)
}
func (s *DutyRosterService) GetHeaderByIDByBaseType(ctx context.Context, id []byte, baseType string) (*dutyroster.HeaderView, error) {
return s.repo.GetHeaderByIDByBaseType(ctx, id, baseType)
}
func (s *DutyRosterService) GetCrewsByRosterIDs(ctx context.Context, rosterIDs [][]byte) ([]dutyroster.CrewView, error) {
return s.repo.GetCrewsByRosterIDs(ctx, rosterIDs)
}
func (s *DutyRosterService) ListHeadersByRange(ctx context.Context, baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error) {
return s.repo.ListHeadersByRange(ctx, baseID, from, to)
}
func (s *DutyRosterService) ListHeadersByRangeByBaseType(ctx context.Context, baseID []byte, from, to time.Time, baseType string) ([]dutyroster.HeaderView, error) {
return s.repo.ListHeadersByRangeByBaseType(ctx, baseID, from, to, baseType)
}
func (s *DutyRosterService) ListHEMSBases(ctx context.Context, baseID []byte) ([]dutyroster.BaseView, error) {
return s.repo.ListHEMSBases(ctx, baseID)
}
func (s *DutyRosterService) ListBasesByType(ctx context.Context, baseID []byte, baseType string) ([]dutyroster.BaseView, error) {
return s.repo.ListBasesByType(ctx, baseID, baseType)
}
// ===== Write-path internals: normalization + persistence helpers =====
func normalizeDutyRosterWriteInput(row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) {
for i := range crews {
crews[i].ShiftStart = normalizeCrewShiftForWrite(crews[i].ShiftStart)
crews[i].ShiftEnd = normalizeCrewShiftForWrite(crews[i].ShiftEnd)
}
}
func normalizeDutyRosterFilterInput(crews []dutyroster.DutyRosterCrew) {
for i := range crews {
crews[i].ShiftStart = normalizeCrewShiftForFilter(crews[i].ShiftStart)
crews[i].ShiftEnd = normalizeCrewShiftForFilter(crews[i].ShiftEnd)
}
}
func normalizeRosterShiftForWrite(v string) string {
s := strings.TrimSpace(v)
if s == "" {
return ""
}
if len(s) == 8 {
return "2000-01-01 " + s
}
if len(s) == 5 {
return "2000-01-01 " + s + ":00"
}
return s
}
func normalizeCrewShiftForWrite(v string) string {
s := strings.TrimSpace(v)
if s == "" {
return "2000-01-01 00:00:00"
}
return normalizeRosterShiftForWrite(s)
}
func normalizeCrewShiftForFilter(v string) string {
s := strings.TrimSpace(v)
if s == "" {
return ""
}
return normalizeRosterShiftForWrite(s)
}
func prepareCrewRowsForRosterInsert(rosterID []byte, crews []dutyroster.DutyRosterCrew) []dutyroster.DutyRosterCrew {
if len(crews) == 0 {
return nil
}
out := make([]dutyroster.DutyRosterCrew, 0, len(crews))
for i := range crews {
row := crews[i]
row.RosterID = append([]byte(nil), rosterID...)
out = append(out, row)
}
return out
}
// Multi-day assignment orchestration.
func (s *DutyRosterService) addRangeAssignments(
ctx context.Context,
txRepo dutyroster.TxRepository,
row *dutyroster.DutyRoster,
crews []dutyroster.DutyRosterCrew,
) error {
assignments := collectRangeAssignments(crews)
if len(assignments) == 0 {
return nil
}
currentDate := dateOnlyUTC(row.DutyDate)
replacedKeys := map[string]struct{}{}
for i := range assignments {
dates := expandDateRange(assignments[i].start, assignments[i].end)
for _, d := range dates {
if d.Equal(currentDate) {
continue
}
rosterID, err := s.ensureRosterForDate(ctx, txRepo, row, d)
if err != nil {
return err
}
insert := assignments[i].crew
insert.RosterID = append([]byte(nil), rosterID...)
replaceKey := replaceByUserKey(insert.RosterID, insert.RoleCode, insert.CrewType, insert.UserID)
if replaceKey != "" {
if _, ok := replacedKeys[replaceKey]; !ok {
if err := txRepo.SoftDeleteCrewByUser(ctx, insert.RosterID, insert.RoleCode, insert.CrewType, insert.UserID, row.UpdatedBy); err != nil {
return err
}
replacedKeys[replaceKey] = struct{}{}
}
}
exists, err := txRepo.CrewExists(ctx, insert)
if err != nil {
return err
}
if exists {
continue
}
if err := txRepo.InsertCrew(ctx, &insert); err != nil {
return err
}
}
}
return nil
}
func (s *DutyRosterService) removeRangeAssignments(
ctx context.Context,
txRepo dutyroster.TxRepository,
row *dutyroster.DutyRoster,
oldCrews []dutyroster.DutyRosterCrew,
newCrews []dutyroster.DutyRosterCrew,
) error {
oldAssignments := collectRangeAssignments(oldCrews)
if len(oldAssignments) == 0 {
return nil
}
newAssignments := collectRangeAssignments(newCrews)
newByKey := make(map[string][]rangeAssignment)
for i := range newAssignments {
k := assignmentKey(newAssignments[i].crew)
newByKey[k] = append(newByKey[k], newAssignments[i])
}
for i := range oldAssignments {
k := assignmentKey(oldAssignments[i].crew)
keep := map[string]struct{}{}
for _, n := range newByKey[k] {
for _, d := range expandDateRange(n.start, n.end) {
keep[d.Format("2006-01-02")] = struct{}{}
}
}
for _, d := range expandDateRange(oldAssignments[i].start, oldAssignments[i].end) {
if _, ok := keep[d.Format("2006-01-02")]; ok {
continue
}
if err := txRepo.SoftDeleteCrewForDate(ctx, row.BaseID, d, oldAssignments[i].crew, row.UpdatedBy); err != nil {
return err
}
}
}
return nil
}
func (s *DutyRosterService) ensureRosterForDate(
ctx context.Context,
txRepo dutyroster.TxRepository,
source *dutyroster.DutyRoster,
dutyDate time.Time,
) ([]byte, error) {
date := dateOnlyUTC(dutyDate)
id, err := txRepo.FindRosterIDByBaseDate(ctx, source.BaseID, date)
if err != nil {
return nil, err
}
if len(id) > 0 {
if _, err := txRepo.LockRosterByID(ctx, id); err != nil {
return nil, err
}
return id, nil
}
newRow := &dutyroster.DutyRoster{
BaseID: append([]byte(nil), source.BaseID...),
DutyDate: date,
CreatedBy: append([]byte(nil), source.CreatedBy...),
UpdatedBy: append([]byte(nil), source.UpdatedBy...),
}
if err := txRepo.CreateHeader(ctx, newRow); err != nil {
id, fetchErr := txRepo.FindRosterIDByBaseDate(ctx, source.BaseID, date)
if fetchErr == nil && len(id) > 0 {
if _, lockErr := txRepo.LockRosterByID(ctx, id); lockErr != nil {
return nil, lockErr
}
return id, nil
}
if fetchErr != nil {
return nil, fetchErr
}
return nil, err
}
return newRow.ID, nil
}
func collectRangeAssignments(crews []dutyroster.DutyRosterCrew) []rangeAssignment {
out := make([]rangeAssignment, 0)
for i := range crews {
c := crews[i]
if !isRoleRangeAssignable(c.RoleCode) {
continue
}
if c.DateStart == nil || c.DateEnd == nil {
continue
}
start := dateOnlyUTC(*c.DateStart)
end := dateOnlyUTC(*c.DateEnd)
if end.Before(start) {
continue
}
out = append(out, rangeAssignment{crew: c, start: start, end: end})
}
return out
}
func assignmentKey(c dutyroster.DutyRosterCrew) string {
return fmt.Sprintf(
"%s|%x|%s|%s",
strings.ToLower(strings.TrimSpace(c.RoleCode)),
c.UserID,
strings.ToLower(strings.TrimSpace(c.CrewType)),
strings.ToLower(strings.TrimSpace(c.NameLabel)),
)
}
func replaceByUserKey(rosterID []byte, roleCode, crewType string, userID []byte) string {
if len(rosterID) == 0 || len(userID) == 0 {
return ""
}
return fmt.Sprintf(
"%x|%s|%s|%x",
rosterID,
strings.ToLower(strings.TrimSpace(roleCode)),
strings.ToLower(strings.TrimSpace(crewType)),
userID,
)
}
func dateOnlyUTC(t time.Time) time.Time {
return time.Date(t.UTC().Year(), t.UTC().Month(), t.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func expandDateRange(start, end time.Time) []time.Time {
start = dateOnlyUTC(start)
end = dateOnlyUTC(end)
if end.Before(start) {
return nil
}
out := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1)
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
out = append(out, d)
}
return out
}
func isRoleRangeAssignable(roleCode string) bool {
switch strings.ToLower(strings.TrimSpace(roleCode)) {
case "other_person", "other":
return false
default:
return true
}
}
// ===== Request parsing + validation helpers =====
func BuildDutyRosterCrewRows(details requestdto.DutyRosterDetailsInput, actor []byte) ([]dutyroster.DutyRosterCrew, *DutyRosterValidationError) {
out := make([]dutyroster.DutyRosterCrew, 0)
seen := map[string]struct{}{}
appendNonPilotRole := func(roleCode string, items []requestdto.DutyRosterCrewInput) *DutyRosterValidationError {
for _, in := range items {
row, errObj := buildDutyRosterCrewRow(
actor,
roleCode,
"main",
in.ID,
"",
"",
"",
in.ShiftDate,
true,
)
if errObj != nil {
return errObj
}
if errObj := appendDutyRosterCrewRow(&out, seen, row); errObj != nil {
return errObj
}
}
return nil
}
appendOtherPersonRole := func(items []requestdto.DutyRosterOtherPersonInput) *DutyRosterValidationError {
for _, in := range items {
if strings.TrimSpace(in.Name) == "" {
return &DutyRosterValidationError{
Pointer: "/data/attributes/roster_detail/other_person",
Detail: "other_person name is required",
}
}
row := dutyroster.DutyRosterCrew{
RoleCode: "other_person",
CrewType: "main",
NameLabel: strings.TrimSpace(in.Name),
MobilePhone: strings.TrimSpace(in.MobilePhone),
Email: strings.TrimSpace(in.Email),
ShiftStart: "",
ShiftEnd: "",
CreatedBy: actor,
UpdatedBy: actor,
}
if errObj := appendDutyRosterCrewRow(&out, seen, row); errObj != nil {
return errObj
}
}
return nil
}
appendPilotRole := func(items []requestdto.DutyRosterPilotCrewInput) *DutyRosterValidationError {
for _, in := range items {
crewType := normalizeCrewType(in.CrewType)
row, errObj := buildDutyRosterCrewRow(
actor,
"pilot",
crewType,
in.ID,
"",
"",
"",
in.ShiftDate,
true,
)
if errObj != nil {
return errObj
}
if crewType == "additional" {
row.FlightInstructor = in.FlightInstructor
row.LineChecker = in.LineChecker
row.Supervisor = in.Supervisor
row.Examiner = in.Examiner
row.CoPilot = in.CoPilot
}
if errObj := appendDutyRosterCrewRow(&out, seen, row); errObj != nil {
return errObj
}
}
return nil
}
if err := appendPilotRole(details.Pilot); err != nil {
return nil, err
}
if err := appendNonPilotRole("doctor", details.Doctor); err != nil {
return nil, err
}
if err := appendNonPilotRole("rescuer", details.Rescuer); err != nil {
return nil, err
}
if err := appendNonPilotRole("helper", details.Helper); err != nil {
return nil, err
}
if err := appendOtherPersonRole(details.OtherPerson); err != nil {
return nil, err
}
return out, nil
}
func buildDutyRosterCrewRow(
actor []byte,
roleCode string,
crewType string,
id string,
name string,
mobilePhone string,
email string,
shiftDate *requestdto.DutyRosterShiftDate,
allowUserID bool,
) (dutyroster.DutyRosterCrew, *DutyRosterValidationError) {
row := dutyroster.DutyRosterCrew{
RoleCode: roleCode,
CrewType: crewType,
NameLabel: strings.TrimSpace(name),
MobilePhone: strings.TrimSpace(mobilePhone),
Email: strings.TrimSpace(email),
ShiftStart: "",
ShiftEnd: "",
CreatedBy: actor,
UpdatedBy: actor,
}
if allowUserID && strings.TrimSpace(id) != "" {
parsedID, err := uuidv7.ParseString(strings.TrimSpace(id))
if err != nil {
return dutyroster.DutyRosterCrew{}, &DutyRosterValidationError{
Pointer: "/data/attributes/roster_detail",
Detail: "crew id is invalid UUID",
}
}
row.UserID = parsedID
}
if errObj := applyCrewShiftDateInput(&row, shiftDate); errObj != nil {
return dutyroster.DutyRosterCrew{}, errObj
}
return row, nil
}
func appendDutyRosterCrewRow(out *[]dutyroster.DutyRosterCrew, seen map[string]struct{}, row dutyroster.DutyRosterCrew) *DutyRosterValidationError {
dupKey := buildDutyRosterCrewDupKey(row)
if _, ok := seen[dupKey]; ok {
return &DutyRosterValidationError{
Pointer: "/data/attributes/roster_detail",
Detail: "duplicate crew assignment in request payload",
}
}
seen[dupKey] = struct{}{}
*out = append(*out, row)
return nil
}
func validateDutyRosterDeleteCrews(crews []dutyroster.DutyRosterCrew) *DutyRosterValidationError {
for i := range crews {
if !roleNeedsShiftDateForDelete(crews[i].RoleCode) {
continue
}
hasStart := crews[i].DateStart != nil
hasEnd := crews[i].DateEnd != nil
if hasStart != hasEnd {
return &DutyRosterValidationError{
Pointer: "/data/attributes/roster_detail",
Detail: "shift_date.date_start and shift_date.date_end must be provided together",
}
}
if hasStart && crews[i].DateEnd.Before(*crews[i].DateStart) {
return &DutyRosterValidationError{
Pointer: "/data/attributes/roster_detail",
Detail: "shift_date.date_end must be >= shift_date.date_start",
}
}
}
return nil
}
func applyCrewShiftDateInput(row *dutyroster.DutyRosterCrew, in *requestdto.DutyRosterShiftDate) *DutyRosterValidationError {
if in == nil {
return nil
}
if strings.TrimSpace(in.DateStart) != "" {
d, err := parseDateOnlyForCrew(in.DateStart)
if err != nil {
return &DutyRosterValidationError{
Pointer: "/data/attributes/roster_detail",
Detail: "ERR_DUTY_ROSTER_SHIFT_DATE_START_FORMAT",
}
}
row.DateStart = &d
}
if strings.TrimSpace(in.DateEnd) != "" {
d, err := parseDateOnlyForCrew(in.DateEnd)
if err != nil {
return &DutyRosterValidationError{
Pointer: "/data/attributes/roster_detail",
Detail: "ERR_DUTY_ROSTER_SHIFT_DATE_END_FORMAT",
}
}
row.DateEnd = &d
}
return nil
}
func buildDutyRosterCrewDupKey(row dutyroster.DutyRosterCrew) string {
return strings.ToLower(strings.TrimSpace(row.RoleCode)) + "|" +
strings.ToLower(strings.TrimSpace(row.CrewType)) + "|" +
uuidNoCtx(row.UserID) + "|" +
strings.ToLower(strings.TrimSpace(row.NameLabel)) + "|" +
dateToString(row.DateStart) + "|" +
dateToString(row.DateEnd) + "|" +
strings.TrimSpace(row.ShiftStart) + "|" +
strings.TrimSpace(row.ShiftEnd)
}
func normalizeClockOrDefaultForCrew(v, def string) string {
s := strings.TrimSpace(v)
if s == "" {
return def
}
if len(s) == 5 {
return s + ":00"
}
return s
}
func normalizeClockInputForCrew(v, def, pointer string) (string, *DutyRosterValidationError) {
out := normalizeClockOrDefaultForCrew(v, def)
if strings.TrimSpace(out) == "" {
return out, nil
}
if _, err := time.Parse("15:04:05", out); err != nil {
return "", &DutyRosterValidationError{
Pointer: pointer,
Detail: "time must be HH:MM or HH:MM:SS",
}
}
return out, nil
}
func NormalizeDutyRosterClockInput(v, def, pointer string) (string, *DutyRosterValidationError) {
return normalizeClockInputForCrew(v, def, pointer)
}
// ===== Date/time parsing helpers used by handlers =====
func ParseDutyRosterShiftRange(raw string) (string, string) {
tokens := extractClockTokensForShift(raw)
if len(tokens) == 0 {
return "", ""
}
start := normalizeClockOrDefaultForCrew(tokens[0], "")
if _, err := time.Parse("15:04:05", start); err != nil {
start = ""
}
end := start
if len(tokens) > 1 {
end = normalizeClockOrDefaultForCrew(tokens[1], "")
if _, err := time.Parse("15:04:05", end); err != nil {
end = ""
}
}
return start, end
}
func ResolveDutyRosterShiftDefaultsForUpdate(ctx context.Context, svc dutyroster.Service, baseID []byte, baseType, currentShiftStart, currentShiftEnd string) (string, string, error) {
defaultShiftStart := strings.TrimSpace(currentShiftStart)
defaultShiftEnd := strings.TrimSpace(currentShiftEnd)
if defaultShiftStart != "" && defaultShiftEnd != "" {
return defaultShiftStart, defaultShiftEnd, nil
}
baseShiftStart, baseShiftEnd, err := svc.ResolveBaseDefaultShiftRange(ctx, baseID, baseType)
if err != nil {
return "", "", err
}
if defaultShiftStart == "" {
defaultShiftStart = baseShiftStart
}
if defaultShiftEnd == "" {
defaultShiftEnd = baseShiftEnd
}
return defaultShiftStart, defaultShiftEnd, nil
}
func extractClockTokensForShift(raw string) []string {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
out := make([]string, 0, 2)
for i := 0; i < len(s) && len(out) < 2; i++ {
if i+5 > len(s) || !isDigitForShift(s[i]) || !isDigitForShift(s[i+1]) || s[i+2] != ':' || !isDigitForShift(s[i+3]) || !isDigitForShift(s[i+4]) {
continue
}
token := s[i : i+5]
if i+8 <= len(s) && s[i+5] == ':' && isDigitForShift(s[i+6]) && isDigitForShift(s[i+7]) {
token = s[i : i+8]
}
out = append(out, token)
}
return out
}
func isDigitForShift(b byte) bool {
return b >= '0' && b <= '9'
}
func ParseDutyRosterDateRangeOrDefault(fromRaw, toRaw string, now time.Time) (time.Time, time.Time, *DutyRosterValidationError) {
defaultFrom := time.Date(now.UTC().Year(), now.UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
defaultTo := defaultFrom.AddDate(0, 1, -1)
fromText := strings.TrimSpace(fromRaw)
toText := strings.TrimSpace(toRaw)
from := defaultFrom
to := defaultTo
if fromText != "" {
parsedFrom, err := parseDateOnlyForCrew(fromText)
if err != nil {
return time.Time{}, time.Time{}, &DutyRosterValidationError{
Pointer: "/query/from",
Detail: "from must be YYYY-MM-DD",
}
}
from = parsedFrom
}
if toText != "" {
parsedTo, err := parseDateOnlyForCrew(toText)
if err != nil {
return time.Time{}, time.Time{}, &DutyRosterValidationError{
Pointer: "/query/to",
Detail: "to must be YYYY-MM-DD",
}
}
to = parsedTo
}
return from, to, nil
}
func ParseDutyRosterDateOnly(v string) (time.Time, error) {
return parseDateOnlyForCrew(v)
}
// ===== Request body decoder helpers =====
func BuildDutyRosterDeleteCrews(raw []byte, actor []byte) ([]dutyroster.DutyRosterCrew, *DutyRosterValidationError) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("{}")) {
return nil, nil
}
var req struct {
Data struct {
Attributes struct {
Detail *requestdto.DutyRosterDetailsInput `json:"roster_detail,omitempty"`
} `json:"attributes"`
} `json:"data"`
}
if err := json.Unmarshal(trimmed, &req); err != nil {
return nil, &DutyRosterValidationError{
Pointer: "/data",
Detail: "invalid delete payload",
}
}
detail := req.Data.Attributes.Detail
if detail == nil {
return nil, nil
}
crews, crewErr := BuildDutyRosterCrewRows(*detail, actor)
if crewErr != nil {
return nil, crewErr
}
if errObj := validateDutyRosterDeleteCrews(crews); errObj != nil {
return nil, errObj
}
return crews, nil
}
func isDutyRosterDetailsInputEmpty(d requestdto.DutyRosterDetailsInput) bool {
return len(d.Pilot) == 0 &&
len(d.Doctor) == 0 &&
len(d.Rescuer) == 0 &&
len(d.Helper) == 0 &&
len(d.OtherPerson) == 0
}
func parseDateOnlyForCrew(v string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", strings.TrimSpace(v), time.UTC)
}
func normalizeCrewType(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
if s == "additional" {
return "additional"
}
return "main"
}
func roleNeedsShiftDateForDelete(roleCode string) bool {
switch strings.ToLower(strings.TrimSpace(roleCode)) {
case "other_person", "other":
return false
default:
return true
}
}
func dateToString(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02")
}
// uuidNoCtx converts a UUID byte slice to string without any context-based lookup.
// Used only for internal deduplication keys where user names are not needed.
func uuidNoCtx(id []byte) string {
if len(id) == 0 {
return ""
}
s, err := uuidv7.BytesToString(id)
if err != nil {
return ""
}
return s
}
func uuidStringOrEmpty(ctx context.Context, id []byte) string {
if len(id) == 0 {
return ""
}
// Try GlobalUserNameGetter first.
if name := getLastNameFromServiceGetter(ctx, id); name != "" {
return name
}
return uuidNoCtx(id)
}
func getLastNameFromServiceGetter(ctx context.Context, id []byte) string {
if name := userctx.GetDisplayName(ctx, id); name != "" {
return name
}
return ""
}
// ===== DTO/resource mapping helpers =====
func DutyRosterResource(ctx context.Context, header dutyroster.HeaderView, crews []dutyroster.CrewView) responsedto.DutyRosterResource {
details := responsedto.DutyRosterDetailsResponse{
Pilot: []responsedto.DutyRosterPilotCrew{},
Doctor: []responsedto.DutyRosterCrew{},
Rescuer: []responsedto.DutyRosterCrew{},
Helper: []responsedto.DutyRosterCrew{},
OtherPerson: []responsedto.DutyRosterCrew{},
}
for i := range crews {
baseItem := responsedto.DutyRosterCrew{
ID: firstNonEmpty(uuidNoCtx(crews[i].UserID), uuidNoCtx(crews[i].ID)),
Name: strings.TrimSpace(crews[i].Name),
Role: strings.TrimSpace(crews[i].Role),
MobilePhone: strings.TrimSpace(crews[i].MobilePhone),
Email: strings.TrimSpace(crews[i].Email),
}
if crews[i].DateStart != nil || crews[i].DateEnd != nil {
baseItem.ShiftDate = &responsedto.DutyRosterShiftDate{DateStart: dateToString(crews[i].DateStart), DateEnd: dateToString(crews[i].DateEnd)}
}
switch roleBucket(crews[i].RoleCode) {
case "pilot":
applyEffectiveCrewShiftTime(&baseItem, crews[i], header)
pilotItem := responsedto.DutyRosterPilotCrew{
ID: baseItem.ID,
Name: baseItem.Name,
Role: baseItem.Role,
CrewType: strings.TrimSpace(crews[i].CrewType),
MobilePhone: baseItem.MobilePhone,
Email: baseItem.Email,
ShiftDate: baseItem.ShiftDate,
}
if strings.EqualFold(strings.TrimSpace(crews[i].CrewType), "additional") {
pilotItem.FlightInstructor = boolPtrIfTrue(crews[i].FlightInstructor)
pilotItem.LineChecker = boolPtrIfTrue(crews[i].LineChecker)
pilotItem.Supervisor = boolPtrIfTrue(crews[i].Supervisor)
pilotItem.Examiner = boolPtrIfTrue(crews[i].Examiner)
pilotItem.CoPilot = boolPtrIfTrue(crews[i].CoPilot)
}
details.Pilot = append(details.Pilot, pilotItem)
case "doctor":
applyEffectiveCrewShiftTime(&baseItem, crews[i], header)
details.Doctor = append(details.Doctor, baseItem)
case "rescuer":
applyEffectiveCrewShiftTime(&baseItem, crews[i], header)
details.Rescuer = append(details.Rescuer, baseItem)
case "helper":
applyEffectiveCrewShiftTime(&baseItem, crews[i], header)
details.Helper = append(details.Helper, baseItem)
default:
baseItem.ShiftDate = nil
baseItem.ShiftTime = nil
details.OtherPerson = append(details.OtherPerson, baseItem)
}
}
return responsedto.DutyRosterResource{
Type: "duty_roster",
ID: uuidNoCtx(header.ID),
Attributes: responsedto.DutyRosterAttributes{
Bases: responsedto.DutyRosterBaseInfo{
ID: uuidNoCtx(header.BaseID),
Type: "hems",
BaseName: header.BaseName,
BaseAbbreviation: header.BaseAbbreviation,
},
Helicopter: buildDutyRosterHelicopterFromHeader(header),
DutyDate: header.DutyDate.UTC().Format("2006-01-02"),
ShiftTime: responsedto.DutyRosterShiftTime{ShiftStart: header.ShiftStart, ShiftEnd: header.ShiftEnd},
Detail: details,
CreatedBy: uuidStringOrEmpty(ctx, header.CreatedBy),
CreatedAt: header.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: header.UpdatedAt.UTC().Format(time.RFC3339),
},
}
}
func applyEffectiveCrewShiftTime(baseItem *responsedto.DutyRosterCrew, crew dutyroster.CrewView, header dutyroster.HeaderView) {
effectiveShiftStart := strings.TrimSpace(crew.ShiftStart)
effectiveShiftEnd := strings.TrimSpace(crew.ShiftEnd)
if effectiveShiftStart == "" {
effectiveShiftStart = header.ShiftStart
}
if effectiveShiftEnd == "" {
effectiveShiftEnd = header.ShiftEnd
}
if effectiveShiftStart == "" && effectiveShiftEnd == "" {
return
}
baseItem.ShiftTime = &responsedto.DutyRosterShiftTime{ShiftStart: effectiveShiftStart, ShiftEnd: effectiveShiftEnd}
}
func buildDutyRosterHelicopterFromHeader(header dutyroster.HeaderView) responsedto.DutyRosterHelicopterSummary {
id := uuidNoCtx(header.HelicopterID)
typ := strings.TrimSpace(header.HelicopterType)
designation := strings.TrimSpace(header.HelicopterDesignation)
identifier := strings.TrimSpace(header.HelicopterIdentifier)
return responsedto.DutyRosterHelicopterSummary{
ID: id,
Designation: designation,
Identifier: identifier,
Type: typ,
}
}
func DutyRosterDeletedCrewItems(rows []dutyroster.DeletedCrewResult) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for i := range rows {
out = append(out, map[string]any{
"id": uuidNoCtx(rows[i].ID),
"roster_id": uuidNoCtx(rows[i].RosterID),
"user_id": uuidNoCtx(rows[i].UserID),
"name": strings.TrimSpace(rows[i].Name),
"role_code": strings.TrimSpace(rows[i].RoleCode),
"crew_type": strings.TrimSpace(rows[i].CrewType),
"date_start": dateToString(rows[i].DateStart),
"date_end": dateToString(rows[i].DateEnd),
})
}
return out
}
func DutyRosterCrewAuditMetadata(rosterID []byte, baseType string, crews []dutyroster.DutyRosterCrew) map[string]any {
items := make([]map[string]any, 0, len(crews))
for i := range crews {
items = append(items, map[string]any{
"user_id": uuidNoCtx(crews[i].UserID),
"name_label": strings.TrimSpace(crews[i].NameLabel),
"role_code": strings.TrimSpace(crews[i].RoleCode),
"crew_type": strings.TrimSpace(crews[i].CrewType),
"date_start": dateToString(crews[i].DateStart),
"date_end": dateToString(crews[i].DateEnd),
})
}
return map[string]any{
"roster_id": uuidNoCtx(rosterID),
"base_type": strings.TrimSpace(baseType),
"crew_count": len(crews),
"crew_detail": items,
}
}
// ===== Grouping/filtering/pagination helpers for list endpoints =====
func GroupRostersByBase(items []responsedto.DutyRosterResource) []responsedto.DutyRosterBaseGroupResource {
return groupRosterResourcesByBase(
items,
func(item responsedto.DutyRosterResource) string { return item.Attributes.Bases.ID },
func(item responsedto.DutyRosterResource) responsedto.DutyRosterBaseGroupResource {
return responsedto.DutyRosterBaseGroupResource{
Type: "hems_base_roster_group",
Attributes: responsedto.DutyRosterBaseGroupAttributes{
Bases: item.Attributes.Bases,
Rosters: []responsedto.DutyRosterGroupRoster{},
},
}
},
func(group *responsedto.DutyRosterBaseGroupResource, item responsedto.DutyRosterResource) {
group.Attributes.Rosters = append(group.Attributes.Rosters, responsedto.DutyRosterGroupRoster{
ID: item.ID,
FlightID: item.Attributes.FlightID,
Helicopter: item.Attributes.Helicopter,
DutyDate: item.Attributes.DutyDate,
ShiftTime: item.Attributes.ShiftTime,
Detail: item.Attributes.Detail,
CreatedBy: item.Attributes.CreatedBy,
CreatedAt: item.Attributes.CreatedAt,
UpdatedAt: item.Attributes.UpdatedAt,
})
},
)
}
func groupRosterResourcesByBase[T any, G any](
items []T,
getBaseID func(item T) string,
newGroup func(item T) G,
appendToGroup func(group *G, item T),
) []G {
out := make([]G, 0)
indexByBase := map[string]int{}
for i := range items {
baseKey := normalizeRosterBaseGroupKey(getBaseID(items[i]))
idx, ok := indexByBase[baseKey]
if !ok {
out = append(out, newGroup(items[i]))
idx = len(out) - 1
indexByBase[baseKey] = idx
}
appendToGroup(&out[idx], items[i])
}
return out
}
func normalizeRosterBaseGroupKey(baseID string) string {
s := strings.TrimSpace(baseID)
if s == "" {
return "unknown"
}
return s
}
func MergeEmptyBaseGroups(groups []responsedto.DutyRosterBaseGroupResource, bases []dutyroster.BaseView) []responsedto.DutyRosterBaseGroupResource {
indexByBase := make(map[string]int, len(groups))
for i := range groups {
baseID := strings.TrimSpace(groups[i].Attributes.Bases.ID)
if baseID == "" || baseID == "unknown" {
continue
}
indexByBase[baseID] = i
}
for i := range bases {
baseID := uuidNoCtx(bases[i].ID)
if baseID == "" {
continue
}
if _, exists := indexByBase[baseID]; exists {
continue
}
groups = append(groups, responsedto.DutyRosterBaseGroupResource{
Type: "hems_base_roster_group",
Attributes: responsedto.DutyRosterBaseGroupAttributes{
Bases: responsedto.DutyRosterBaseInfo{
ID: baseID,
Type: "hems",
BaseName: strings.TrimSpace(bases[i].BaseName),
BaseAbbreviation: strings.TrimSpace(bases[i].BaseAbbreviation),
},
Rosters: []responsedto.DutyRosterGroupRoster{},
},
})
}
return groups
}
func LoadHEMSRosterGroups(ctx context.Context, svc dutyroster.Service, baseID []byte, from, to time.Time, baseName string) ([]responsedto.DutyRosterBaseGroupResource, error) {
headers, err := svc.ListHeadersByRange(ctx, baseID, from, to)
if err != nil {
return nil, err
}
bases, err := svc.ListHEMSBases(ctx, baseID)
if err != nil {
return nil, err
}
rosterIDs := make([][]byte, 0, len(headers))
for i := range headers {
rosterIDs = append(rosterIDs, headers[i].ID)
}
crews, err := svc.GetCrewsByRosterIDs(ctx, rosterIDs)
if err != nil {
return nil, err
}
byRoster := make(map[string][]dutyroster.CrewView)
for i := range crews {
k, _ := uuidv7.BytesToString(crews[i].RosterID)
byRoster[k] = append(byRoster[k], crews[i])
}
out := make([]responsedto.DutyRosterResource, 0, len(headers))
for i := range headers {
k, _ := uuidv7.BytesToString(headers[i].ID)
out = append(out, DutyRosterResource(ctx, headers[i], byRoster[k]))
}
grouped := MergeEmptyBaseGroups(GroupRostersByBase(out), bases)
needle := strings.ToLower(strings.TrimSpace(baseName))
if needle == "" {
return grouped, nil
}
filtered := make([]responsedto.DutyRosterBaseGroupResource, 0, len(grouped))
for i := range grouped {
name := strings.ToLower(strings.TrimSpace(grouped[i].Attributes.Bases.BaseName))
if strings.Contains(name, needle) {
filtered = append(filtered, grouped[i])
}
}
return filtered, nil
}
func PaginateHEMSRosterGroups(groups []responsedto.DutyRosterBaseGroupResource, start, length int) ([]responsedto.DutyRosterBaseGroupResource, int) {
total := len(groups)
if start > total {
start = total
}
end := start + length
if end > total {
end = total
}
return groups[start:end], total
}
// ===== Small generic helpers =====
func roleBucket(code string) string {
s := strings.ToLower(strings.TrimSpace(code))
switch s {
case "pilot":
return "pilot"
case "doctor":
return "doctor"
case "air_rescuer", "rescuer":
return "rescuer"
case "flight_assistant", "helper":
return "helper"
case "other", "other_person":
return "other_person"
default:
return "other_person"
}
}
func firstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
func boolPtrIfTrue(v bool) *bool {
if !v {
return nil
}
b := true
return &b
}