package handlers import ( "context" "strings" "time" dutyroster "wucher/internal/domain/duty_roster" "wucher/internal/shared/pkg/userctx" "wucher/internal/shared/pkg/uuidv7" responsedto "wucher/internal/transport/http/dto/response" ) func loadRosterGroups(ctx context.Context, svc dutyroster.Service, baseID []byte, from, to time.Time, baseName, baseType string) ([]responsedto.DutyRosterBaseGroupResource, error) { bt := normalizeRosterBaseType(baseType) var ( headers []dutyroster.HeaderView bases []dutyroster.BaseView err error ) if bt == "base" { headers, err = svc.ListHeadersByRangeByBaseType(ctx, baseID, from, to, "base") if err != nil { return nil, err } bases, err = svc.ListBasesByType(ctx, baseID, "base") } else { 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], bt)) } grouped := mergeEmptyRosterGroups(groupRostersByBase(out, bt), bases, bt) 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 paginateRosterGroups(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 } func paginateUnifiedRosterGroups( hems []responsedto.DutyRosterBaseGroupResource, base []responsedto.DutyRosterBaseGroupResource, start, length int, ) ([]responsedto.DutyRosterBaseGroupResource, []responsedto.DutyRosterBaseGroupResource, int) { total := len(hems) + len(base) if start > total { start = total } end := start + length if end > total { end = total } if start >= end { return []responsedto.DutyRosterBaseGroupResource{}, []responsedto.DutyRosterBaseGroupResource{}, total } outHEMS := []responsedto.DutyRosterBaseGroupResource{} outBase := []responsedto.DutyRosterBaseGroupResource{} hemsTotal := len(hems) if start < hemsTotal { hemsEnd := end if hemsEnd > hemsTotal { hemsEnd = hemsTotal } outHEMS = hems[start:hemsEnd] } baseStart := start - hemsTotal if baseStart < 0 { baseStart = 0 } if end > hemsTotal && baseStart < len(base) { baseEnd := end - hemsTotal if baseEnd > len(base) { baseEnd = len(base) } if baseStart < baseEnd { outBase = base[baseStart:baseEnd] } } return outHEMS, outBase, total } func dutyRosterResource(ctx context.Context, header dutyroster.HeaderView, crews []dutyroster.CrewView, baseType string) responsedto.DutyRosterResource { bt := normalizeRosterBaseType(baseType) 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: firstNonEmptyString(idString(crews[i].UserID), idString(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 roleNeedsShiftDateForDelete(crews[i].RoleCode) { baseItem.ShiftDate = &responsedto.DutyRosterShiftDate{DateStart: dateToString(crews[i].DateStart), DateEnd: dateToString(crews[i].DateEnd)} } effectiveShiftStart := strings.TrimSpace(crews[i].ShiftStart) effectiveShiftEnd := strings.TrimSpace(crews[i].ShiftEnd) if effectiveShiftStart == "" { effectiveShiftStart = strings.TrimSpace(header.ShiftStart) } if effectiveShiftEnd == "" { effectiveShiftEnd = strings.TrimSpace(header.ShiftEnd) } switch strings.ToLower(strings.TrimSpace(crews[i].RoleCode)) { case "pilot": pilotItem := responsedto.DutyRosterPilotCrew{ID: baseItem.ID, Name: baseItem.Name, Role: firstNonEmptyString(baseItem.Role, "pilot"), MobilePhone: baseItem.MobilePhone, Email: baseItem.Email, ShiftDate: baseItem.ShiftDate, CrewType: strings.ToLower(strings.TrimSpace(crews[i].CrewType)), FlightInstructor: boolPtrDR(crews[i].FlightInstructor), LineChecker: boolPtrDR(crews[i].LineChecker), Supervisor: boolPtrDR(crews[i].Supervisor), Examiner: boolPtrDR(crews[i].Examiner), CoPilot: boolPtrDR(crews[i].CoPilot)} details.Pilot = append(details.Pilot, pilotItem) case "doctor": if effectiveShiftStart != "" || effectiveShiftEnd != "" { baseItem.ShiftTime = &responsedto.DutyRosterShiftTime{ShiftStart: effectiveShiftStart, ShiftEnd: effectiveShiftEnd} } baseItem.Role = firstNonEmptyString(baseItem.Role, "doctor") details.Doctor = append(details.Doctor, baseItem) case "rescuer", "air_rescuer": if effectiveShiftStart != "" || effectiveShiftEnd != "" { baseItem.ShiftTime = &responsedto.DutyRosterShiftTime{ShiftStart: effectiveShiftStart, ShiftEnd: effectiveShiftEnd} } baseItem.Role = firstNonEmptyString(baseItem.Role, "rescuer") details.Rescuer = append(details.Rescuer, baseItem) case "helper", "flight_assistant": if effectiveShiftStart != "" || effectiveShiftEnd != "" { baseItem.ShiftTime = &responsedto.DutyRosterShiftTime{ShiftStart: effectiveShiftStart, ShiftEnd: effectiveShiftEnd} } baseItem.Role = firstNonEmptyString(baseItem.Role, "helper") details.Helper = append(details.Helper, baseItem) default: baseItem.Role = firstNonEmptyString(baseItem.Role, "other") details.OtherPerson = append(details.OtherPerson, baseItem) } } return responsedto.DutyRosterResource{Type: rosterResourceTypeFor(bt), ID: idString(header.ID), Attributes: responsedto.DutyRosterAttributes{Bases: responsedto.DutyRosterBaseInfo{ID: idString(header.BaseID), Type: bt, BaseName: strings.TrimSpace(header.BaseName), BaseAbbreviation: strings.TrimSpace(header.BaseAbbreviation)}, Helicopter: buildRosterHelicopterFromHeader(header), DutyDate: header.DutyDate.UTC().Format("2006-01-02"), ShiftTime: responsedto.DutyRosterShiftTime{ShiftStart: header.ShiftStart, ShiftEnd: header.ShiftEnd}, Detail: details, CreatedBy: uuidStringOrEmptyDR(ctx, header.CreatedBy), CreatedAt: header.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: header.UpdatedAt.UTC().Format(time.RFC3339)}} } func groupRostersByBase(items []responsedto.DutyRosterResource, baseType string) []responsedto.DutyRosterBaseGroupResource { bt := normalizeRosterBaseType(baseType) groupType := rosterGroupTypeFor(bt) out := make([]responsedto.DutyRosterBaseGroupResource, 0) indexByBase := map[string]int{} for i := range items { baseID := strings.TrimSpace(items[i].Attributes.Bases.ID) if baseID == "" { baseID = "unknown" } idx, ok := indexByBase[baseID] if !ok { idx = len(out) indexByBase[baseID] = idx out = append(out, responsedto.DutyRosterBaseGroupResource{Type: groupType, Attributes: responsedto.DutyRosterBaseGroupAttributes{Bases: items[i].Attributes.Bases, Rosters: []responsedto.DutyRosterGroupRoster{}}}) } out[idx].Attributes.Rosters = append(out[idx].Attributes.Rosters, responsedto.DutyRosterGroupRoster{ID: items[i].ID, FlightID: items[i].Attributes.FlightID, Helicopter: items[i].Attributes.Helicopter, DutyDate: items[i].Attributes.DutyDate, ShiftTime: items[i].Attributes.ShiftTime, Detail: items[i].Attributes.Detail, CreatedBy: items[i].Attributes.CreatedBy, CreatedAt: items[i].Attributes.CreatedAt, UpdatedAt: items[i].Attributes.UpdatedAt}) } return out } func mergeEmptyRosterGroups(groups []responsedto.DutyRosterBaseGroupResource, bases []dutyroster.BaseView, baseType string) []responsedto.DutyRosterBaseGroupResource { bt := normalizeRosterBaseType(baseType) groupType := rosterGroupTypeFor(bt) 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 := idString(bases[i].ID) if baseID == "" { continue } if _, exists := indexByBase[baseID]; exists { continue } groups = append(groups, responsedto.DutyRosterBaseGroupResource{Type: groupType, Attributes: responsedto.DutyRosterBaseGroupAttributes{Bases: responsedto.DutyRosterBaseInfo{ID: baseID, Type: bt, BaseName: strings.TrimSpace(bases[i].BaseName), BaseAbbreviation: strings.TrimSpace(bases[i].BaseAbbreviation)}, Rosters: []responsedto.DutyRosterGroupRoster{}}}) } return groups } 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": idString(rows[i].ID), "roster_id": idString(rows[i].RosterID), "user_id": idString(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 { roles := make([]string, 0, len(crews)) for i := range crews { roles = append(roles, roleBucket(crews[i].RoleCode)) } return map[string]any{"roster_id": idString(rosterID), "base_type": strings.ToLower(strings.TrimSpace(baseType)), "roles": roles, "crew_count": len(crews)} } func buildRosterHelicopterFromHeader(header dutyroster.HeaderView) responsedto.DutyRosterHelicopterSummary { return responsedto.DutyRosterHelicopterSummary{ID: idString(header.HelicopterID), Designation: strings.TrimSpace(header.HelicopterDesignation), Identifier: strings.TrimSpace(header.HelicopterIdentifier), Type: strings.TrimSpace(header.HelicopterType)} } func uuidStringOrEmptyDR(ctx context.Context, id []byte) string { if len(id) == 0 { return "" } if name := getLastNameFromPresenterGetter(ctx, id); name != "" { return name } return idString(id) } func getLastNameFromPresenterGetter(ctx context.Context, id []byte) string { if name := userctx.GetDisplayName(ctx, id); name != "" { return name } return "" } func firstNonEmptyString(a, b string) string { if strings.TrimSpace(a) != "" { return strings.TrimSpace(a) } return strings.TrimSpace(b) } func roleBucket(code string) string { switch strings.ToLower(strings.TrimSpace(code)) { case "pilot": return "pilot" case "doctor": return "doctor" case "rescuer", "air_rescuer": return "rescuer" case "helper", "flight_assistant": return "helper" default: return "other_person" } } func roleNeedsShiftDateForDelete(roleCode string) bool { switch strings.ToLower(strings.TrimSpace(roleCode)) { case "other_person", "other": return false default: return true } } func boolPtrDR(v bool) *bool { return &v } func normalizeRosterBaseType(baseType string) string { if strings.EqualFold(strings.TrimSpace(baseType), "base") { return "base" } return "hems" } func rosterResourceTypeFor(baseType string) string { if normalizeRosterBaseType(baseType) == "base" { return "base_duty_roster" } return "duty_roster" } func rosterGroupTypeFor(baseType string) string { if normalizeRosterBaseType(baseType) == "base" { return "base_roster_group" } return "hems_base_roster_group" }