1247 lines
36 KiB
Go
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
|
|
}
|