package mysql import ( "context" "errors" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" basedomain "wucher/internal/domain/base" dutyroster "wucher/internal/domain/duty_roster" ) type DutyRosterRepository struct { db *gorm.DB } func NewDutyRosterRepository(db *gorm.DB) *DutyRosterRepository { return &DutyRosterRepository{db: db} } func (r *DutyRosterRepository) InTx(ctx context.Context, fn func(txRepo dutyroster.TxRepository) error) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return fn(&DutyRosterRepository{db: tx}) }) } func (r *DutyRosterRepository) GetRosterByID(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error) { row := &dutyroster.DutyRoster{} if err := r.db.WithContext(ctx). Where("id = ? AND deleted_at IS NULL", id). Take(row).Error; err != nil { return nil, err } return row, nil } func (r *DutyRosterRepository) LockRosterByID(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error) { row := &dutyroster.DutyRoster{} if err := r.db.WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ? AND deleted_at IS NULL", id). Take(row).Error; err != nil { return nil, err } return row, nil } func (r *DutyRosterRepository) CreateHeader(ctx context.Context, row *dutyroster.DutyRoster) error { return r.db.WithContext(ctx).Create(row).Error } func (r *DutyRosterRepository) UpdateHeader(ctx context.Context, row *dutyroster.DutyRoster) error { updates := map[string]any{ "base_id": row.BaseID, "duty_date": row.DutyDate, "updated_by": row.UpdatedBy, } res := r.db.WithContext(ctx).Model(&dutyroster.DutyRoster{}). Where("id = ? AND deleted_at IS NULL", row.ID). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *DutyRosterRepository) SoftDeleteHeader(ctx context.Context, id []byte, deletedBy []byte) error { now := time.Now().UTC() res := r.db.WithContext(ctx).Model(&dutyroster.DutyRoster{}). Where("id = ? AND deleted_at IS NULL", id). Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *DutyRosterRepository) SoftDeleteCrewsByRosterID(ctx context.Context, rosterID []byte, deletedBy []byte) error { now := time.Now().UTC() return r.db.WithContext(ctx).Model(&dutyroster.DutyRosterCrew{}). Where("roster_id = ? AND deleted_at IS NULL", rosterID). Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}).Error } func (r *DutyRosterRepository) HardDeleteHeader(ctx context.Context, id []byte) error { return r.db.WithContext(ctx). Where("id = ?", id). Delete(&dutyroster.DutyRoster{}).Error } func (r *DutyRosterRepository) HardDeleteCrewsByRosterID(ctx context.Context, rosterID []byte) error { return r.db.WithContext(ctx). Where("roster_id = ?", rosterID). Delete(&dutyroster.DutyRosterCrew{}).Error } func (r *DutyRosterRepository) ListActiveCrewsByRosterID(ctx context.Context, rosterID []byte) ([]dutyroster.DutyRosterCrew, error) { rows := make([]dutyroster.DutyRosterCrew, 0) if err := r.db.WithContext(ctx). Where("roster_id = ? AND deleted_at IS NULL", rosterID). Find(&rows).Error; err != nil { return nil, err } return rows, nil } func (r *DutyRosterRepository) InsertCrew(ctx context.Context, row *dutyroster.DutyRosterCrew) error { return r.db.WithContext(ctx).Create(row).Error } func (r *DutyRosterRepository) CrewExists(ctx context.Context, row dutyroster.DutyRosterCrew) (bool, error) { return crewExists(r.db.WithContext(ctx), row) } func (r *DutyRosterRepository) SoftDeleteCrewByUser(ctx context.Context, rosterID []byte, roleCode, crewType string, userID, updatedBy []byte) error { return softReplaceCrewByUser(r.db.WithContext(ctx), rosterID, roleCode, crewType, userID, updatedBy) } func (r *DutyRosterRepository) FindRosterIDByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([]byte, error) { date := dateOnlyUTC(dutyDate) var row dutyroster.DutyRoster err := r.db.WithContext(ctx). Where("base_id = ? AND duty_date = ? AND deleted_at IS NULL", baseID, date). Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return row.ID, nil } func (r *DutyRosterRepository) FindRosterIDsByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([][]byte, error) { return findRosterIDsByBaseAndDate(r.db.WithContext(ctx), baseID, dutyDate) } func (r *DutyRosterRepository) MatchCrewByFilter(ctx context.Context, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) { return matchCrewByFilter(r.db.WithContext(ctx), rosterIDs, crew) } func (r *DutyRosterRepository) SoftDeleteCrewRows(ctx context.Context, rows []dutyroster.DeletedCrewResult, deletedBy []byte) error { return softDeleteCrewByIDs(r.db.WithContext(ctx), rows, time.Now().UTC(), deletedBy) } func (r *DutyRosterRepository) SoftDeleteCrewForDate(ctx context.Context, baseID []byte, dutyDate time.Time, crew dutyroster.DutyRosterCrew, updatedBy []byte) error { return softDeleteCrewForDate(r.db.WithContext(ctx), baseID, dutyDate, crew, time.Now().UTC(), updatedBy) } func (r *DutyRosterRepository) GetBaseDefaultShift(ctx context.Context, baseID []byte, baseType string) (string, error) { hasDefaultShiftTypes := r.hasBaseDefaultShiftTypeColumns() q := r.db.WithContext(ctx). Table("bases b"). Where("b.id = ?", baseID) if hasDefaultShiftTypes { q = q.Select("b.default_start_time_type, b.default_end_time_type, b.default_shift_start, b.default_shift_end") } else { q = q.Select("b.default_shift_start, b.default_shift_end") } if strings.TrimSpace(baseType) != "" { q = q.Joins("JOIN base_categories bc ON bc.id = b.base_category_id"). Where("bc.key = ?", rosterBaseCategoryKey(baseType)) } var out struct { DefaultStartTimeType string DefaultEndTimeType string DefaultShiftStart string DefaultShiftEnd string } err := q.Take(&out).Error if err != nil { return "", err } start := "" end := "" if !hasDefaultShiftTypes || normalizeBaseShiftTimeType(out.DefaultStartTimeType) == basedomain.ShiftTimeTypeFixed { start = strings.TrimSpace(out.DefaultShiftStart) } if !hasDefaultShiftTypes || normalizeBaseShiftTimeType(out.DefaultEndTimeType) == basedomain.ShiftTimeTypeFixed { end = strings.TrimSpace(out.DefaultShiftEnd) } if start != "" || end != "" { switch { case start != "" && end != "": return start + "-" + end, nil case start != "": return start, nil default: return end, nil } } return "", nil } func (r *DutyRosterRepository) GetHeaderByID(ctx context.Context, id []byte) (*dutyroster.HeaderView, error) { return r.GetHeaderByIDByBaseType(ctx, id, "hems") } func (r *DutyRosterRepository) GetHeaderByIDByBaseType(ctx context.Context, id []byte, baseType string) (*dutyroster.HeaderView, error) { categoryKey := rosterBaseCategoryKey(baseType) out := &dutyroster.HeaderView{} defaultShiftStartExpr := r.baseDefaultShiftDisplayExpr("hb", "default_shift_start", "default_start_time_type") defaultShiftEndExpr := r.baseDefaultShiftDisplayExpr("hb", "default_shift_end", "default_end_time_type") query := r.db.WithContext(ctx). Table("duty_rosters hdr"). Select(` hdr.id, hdr.base_id AS base_id, hb.base AS base_name, hb.base_abbreviation, `+defaultShiftStartExpr+` AS default_shift_start, `+defaultShiftEndExpr+` AS default_shift_end, '' AS helicopter_id, '' AS helicopter_type, '' AS helicopter_designation, '' AS helicopter_identifier, hdr.duty_date, TIME_FORMAT(hdr.shift_start, '%H:%i:%s') AS shift_start, TIME_FORMAT(hdr.shift_end, '%H:%i:%s') AS shift_end, hdr.created_at, hdr.created_by, hdr.updated_at`). Joins("LEFT JOIN bases hb ON hb.id = hdr.base_id"). Joins("LEFT JOIN base_categories bc ON bc.id = hb.base_category_id"). Where("hdr.id = ? AND hdr.deleted_at IS NULL", id) err := query.Where("bc.key = ?", categoryKey). Where("hb.id IS NOT NULL"). Take(out).Error if errors.Is(err, gorm.ErrRecordNotFound) || len(out.ID) == 0 { out = &dutyroster.HeaderView{} err = r.db.WithContext(ctx). Table("duty_rosters hdr"). Select(` hdr.id, hdr.base_id AS base_id, hb.base AS base_name, hb.base_abbreviation, `+defaultShiftStartExpr+` AS default_shift_start, `+defaultShiftEndExpr+` AS default_shift_end, '' AS helicopter_id, '' AS helicopter_type, '' AS helicopter_designation, '' AS helicopter_identifier, hdr.duty_date, TIME_FORMAT(hdr.shift_start, '%H:%i:%s') AS shift_start, TIME_FORMAT(hdr.shift_end, '%H:%i:%s') AS shift_end, hdr.created_at, hdr.created_by, hdr.updated_at`). Joins("LEFT JOIN bases hb ON hb.id = hdr.base_id"). Where("hdr.id = ? AND hdr.deleted_at IS NULL", id). Where("hb.id IS NOT NULL"). Take(out).Error } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return out, nil } func (r *DutyRosterRepository) hasBaseDefaultShiftTypeColumns() bool { if r == nil || r.db == nil { return false } return r.db.Migrator().HasColumn("bases", "default_start_time_type") && r.db.Migrator().HasColumn("bases", "default_end_time_type") } func (r *DutyRosterRepository) baseDefaultShiftDisplayExpr(alias, shiftColumn, typeColumn string) string { qualifiedShift := alias + "." + shiftColumn if !r.hasBaseDefaultShiftTypeColumns() { return "CASE WHEN " + qualifiedShift + " IS NULL THEN '' ELSE TIME_FORMAT(" + qualifiedShift + ", '%H:%i:%s') END" } qualifiedType := alias + "." + typeColumn return "CASE WHEN COALESCE(" + qualifiedType + ", 'FIXED') <> 'FIXED' OR " + qualifiedShift + " IS NULL THEN '' ELSE TIME_FORMAT(" + qualifiedShift + ", '%H:%i:%s') END" } func (r *DutyRosterRepository) GetCrewsByRosterIDs(ctx context.Context, rosterIDs [][]byte) ([]dutyroster.CrewView, error) { if len(rosterIDs) == 0 { return []dutyroster.CrewView{}, nil } rows := make([]dutyroster.CrewView, 0) err := r.db.WithContext(ctx). Table("duty_roster_crews c"). Select(` c.id, c.roster_id, c.user_id, COALESCE(NULLIF(c.name_label, ''), TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))), '') AS name, COALESCE(NULLIF(r.name, ''), c.role_code) AS role, c.role_code, c.crew_type, COALESCE(NULLIF(c.mobile_phone, ''), u.mobile_phone, '') AS mobile_phone, COALESCE(NULLIF(c.email, ''), u.email, '') AS email, c.date_start, c.date_end, CASE WHEN c.shift_start IS NULL OR TIME_FORMAT(c.shift_start, '%H:%i:%s') = '00:00:00' THEN '' ELSE TIME_FORMAT(c.shift_start, '%H:%i:%s') END AS shift_start, CASE WHEN c.shift_end IS NULL OR TIME_FORMAT(c.shift_end, '%H:%i:%s') = '00:00:00' THEN '' ELSE TIME_FORMAT(c.shift_end, '%H:%i:%s') END AS shift_end, c.flight_instructor, c.line_checker, c.supervisor, c.examiner, c.co_pilot`). Joins("LEFT JOIN users u ON u.id = c.user_id"). Joins("LEFT JOIN roles r ON r.id = u.role_id"). Where("c.roster_id IN ? AND c.deleted_at IS NULL", rosterIDs). Order("c.role_code ASC, c.crew_type ASC, c.created_at ASC"). Scan(&rows).Error if err != nil { return nil, err } return rows, nil } func (r *DutyRosterRepository) ListHeadersByRange(ctx context.Context, baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error) { return r.ListHeadersByRangeByBaseType(ctx, baseID, from, to, "hems") } func (r *DutyRosterRepository) ListHeadersByRangeByBaseType(ctx context.Context, baseID []byte, from, to time.Time, baseType string) ([]dutyroster.HeaderView, error) { categoryKey := rosterBaseCategoryKey(baseType) rows := make([]dutyroster.HeaderView, 0) q := r.db.WithContext(ctx). Table("duty_rosters hdr"). Select(` hdr.id, hdr.base_id AS base_id, hb.base AS base_name, hb.base_abbreviation, '' AS helicopter_id, '' AS helicopter_type, '' AS helicopter_designation, '' AS helicopter_identifier, hdr.duty_date, TIME_FORMAT(hdr.shift_start, '%H:%i:%s') AS shift_start, TIME_FORMAT(hdr.shift_end, '%H:%i:%s') AS shift_end, hdr.created_at, hdr.created_by, hdr.updated_at`). Joins("LEFT JOIN bases hb ON hb.id = hdr.base_id"). Joins("LEFT JOIN base_categories bc ON bc.id = hb.base_category_id"). Where("hdr.deleted_at IS NULL AND hdr.duty_date BETWEEN ? AND ?", from, to). Where("bc.key = ?", categoryKey). Where("hb.id IS NOT NULL") if len(baseID) == 16 { q = q.Where("hdr.base_id = ?", baseID) } err := q.Order("hb.base ASC, hdr.duty_date ASC").Scan(&rows).Error if err != nil { return nil, err } return rows, nil } func (r *DutyRosterRepository) ListHEMSBases(ctx context.Context, baseID []byte) ([]dutyroster.BaseView, error) { return r.ListBasesByType(ctx, baseID, "hems") } func (r *DutyRosterRepository) ListBasesByType(ctx context.Context, baseID []byte, baseType string) ([]dutyroster.BaseView, error) { categoryKey := rosterBaseCategoryKey(baseType) rows := make([]dutyroster.BaseView, 0) q := r.db.WithContext(ctx). Table("bases b"). Select(` b.id, b.base AS base_name, b.base_abbreviation`). Joins("JOIN base_categories bc ON bc.id = b.base_category_id"). Where("b.is_active = ?", true). Where("bc.key = ?", categoryKey) if len(baseID) == 16 { q = q.Where("b.id = ?", baseID) } if err := q.Order("CASE WHEN b.sortkey IS NULL THEN 1 ELSE 0 END ASC, b.sortkey ASC, b.base ASC").Scan(&rows).Error; err != nil { return nil, err } return rows, nil } func rosterBaseCategoryKey(baseType string) string { switch strings.ToLower(strings.TrimSpace(baseType)) { case "base": return basedomain.CategoryKeyRegular default: return basedomain.CategoryKeyHEMS } } 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 softReplaceCrewByUser(tx *gorm.DB, rosterID []byte, roleCode, crewType string, userID []byte, updatedBy []byte) error { if len(rosterID) == 0 || len(userID) == 0 { return nil } now := time.Now().UTC() return tx.Model(&dutyroster.DutyRosterCrew{}). Where("roster_id = ? AND role_code = ? AND crew_type = ? AND user_id = ? AND deleted_at IS NULL", rosterID, roleCode, crewType, userID). Updates(map[string]any{ "deleted_at": now, "deleted_by": updatedBy, "updated_by": updatedBy, }).Error } func crewExists(tx *gorm.DB, row dutyroster.DutyRosterCrew) (bool, error) { q := tx.Model(&dutyroster.DutyRosterCrew{}). Where("roster_id = ? AND role_code = ? AND crew_type = ? AND deleted_at IS NULL", row.RosterID, row.RoleCode, row.CrewType). Where("name_label = ? AND mobile_phone = ? AND email = ?", row.NameLabel, row.MobilePhone, row.Email). Where("shift_start = ? AND shift_end = ?", row.ShiftStart, row.ShiftEnd) if len(row.UserID) == 0 { q = q.Where("user_id IS NULL") } else { q = q.Where("user_id = ?", row.UserID) } if row.DateStart == nil { q = q.Where("date_start IS NULL") } else { q = q.Where("date_start = ?", dateOnlyUTC(*row.DateStart)) } if row.DateEnd == nil { q = q.Where("date_end IS NULL") } else { q = q.Where("date_end = ?", dateOnlyUTC(*row.DateEnd)) } var count int64 if err := q.Count(&count).Error; err != nil { return false, err } return count > 0, nil } func softDeleteCrewForDate( tx *gorm.DB, baseID []byte, dutyDate time.Time, crew dutyroster.DutyRosterCrew, now time.Time, updatedBy []byte, ) error { date := dateOnlyUTC(dutyDate) var rosterIDs [][]byte if err := tx. Table("duty_rosters"). Select("id"). Where("base_id = ? AND duty_date = ? AND deleted_at IS NULL", baseID, date). Scan(&rosterIDs).Error; err != nil { return err } if len(rosterIDs) == 0 { return nil } q := tx.Model(&dutyroster.DutyRosterCrew{}). Where("roster_id IN ? AND role_code = ? AND crew_type = ? AND deleted_at IS NULL", rosterIDs, crew.RoleCode, crew.CrewType). Where("name_label = ? AND mobile_phone = ? AND email = ? AND shift_start = ? AND shift_end = ?", crew.NameLabel, crew.MobilePhone, crew.Email, crew.ShiftStart, crew.ShiftEnd, ) if len(crew.UserID) == 0 { q = q.Where("user_id IS NULL") } else { q = q.Where("user_id = ?", crew.UserID) } if crew.DateStart == nil { q = q.Where("date_start IS NULL") } else { q = q.Where("date_start = ?", dateOnlyUTC(*crew.DateStart)) } if crew.DateEnd == nil { q = q.Where("date_end IS NULL") } else { q = q.Where("date_end = ?", dateOnlyUTC(*crew.DateEnd)) } return q.Updates(map[string]any{ "deleted_at": now, "deleted_by": updatedBy, "updated_by": updatedBy, }).Error } func findRosterIDsByBaseAndDate(tx *gorm.DB, baseID []byte, dutyDate time.Time) ([][]byte, error) { type rowID struct { ID []byte `gorm:"column:id"` } date := dateOnlyUTC(dutyDate) rows := make([]rowID, 0) if err := tx. Table("duty_rosters"). Select("id"). Where("base_id = ? AND duty_date = ? AND deleted_at IS NULL", baseID, date). Scan(&rows).Error; err != nil { return nil, err } out := make([][]byte, 0, len(rows)) for i := range rows { out = append(out, rows[i].ID) } return out, nil } func softDeleteCrewByFilter( tx *gorm.DB, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew, now time.Time, updatedBy []byte, ) error { if len(rosterIDs) == 0 { return nil } q := tx.Model(&dutyroster.DutyRosterCrew{}). Table("duty_roster_crews c"). Where("c.roster_id IN ? AND c.role_code = ? AND c.deleted_at IS NULL", rosterIDs, crew.RoleCode) if strings.TrimSpace(crew.CrewType) != "" { q = q.Where("c.crew_type = ?", crew.CrewType) } if len(crew.UserID) > 0 { q = q.Where("c.user_id = ?", crew.UserID) } else if strings.TrimSpace(crew.NameLabel) != "" { q = q.Where("c.name_label = ?", crew.NameLabel) } if crew.DateStart != nil { q = q.Where("c.date_start = ?", dateOnlyUTC(*crew.DateStart)) } if crew.DateEnd != nil { q = q.Where("c.date_end = ?", dateOnlyUTC(*crew.DateEnd)) } if strings.TrimSpace(crew.ShiftStart) != "" { q = q.Where("c.shift_start = ?", crew.ShiftStart) } if strings.TrimSpace(crew.ShiftEnd) != "" { q = q.Where("c.shift_end = ?", crew.ShiftEnd) } return q.Updates(map[string]any{ "deleted_at": now, "deleted_by": updatedBy, "updated_by": updatedBy, }).Error } func matchCrewByFilter(tx *gorm.DB, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) { if len(rosterIDs) == 0 { return []dutyroster.DeletedCrewResult{}, nil } q := tx.Model(&dutyroster.DutyRosterCrew{}). Select(` c.id, c.roster_id, c.user_id, COALESCE(NULLIF(c.name_label, ''), TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))), '') AS name, c.role_code, c.crew_type, c.date_start, c.date_end`). Table("duty_roster_crews c"). Joins("LEFT JOIN users u ON u.id = c.user_id"). Where("roster_id IN ? AND role_code = ? AND deleted_at IS NULL", rosterIDs, crew.RoleCode) if strings.TrimSpace(crew.CrewType) != "" { q = q.Where("crew_type = ?", crew.CrewType) } if len(crew.UserID) > 0 { q = q.Where("user_id = ?", crew.UserID) } else if strings.TrimSpace(crew.NameLabel) != "" { q = q.Where("name_label = ?", crew.NameLabel) } if crew.DateStart != nil { q = q.Where("date_start = ?", dateOnlyUTC(*crew.DateStart)) } if crew.DateEnd != nil { q = q.Where("date_end = ?", dateOnlyUTC(*crew.DateEnd)) } if strings.TrimSpace(crew.ShiftStart) != "" { q = q.Where("shift_start = ?", crew.ShiftStart) } if strings.TrimSpace(crew.ShiftEnd) != "" { q = q.Where("shift_end = ?", crew.ShiftEnd) } out := make([]dutyroster.DeletedCrewResult, 0) if err := q.Scan(&out).Error; err != nil { return nil, err } return out, nil } func softDeleteCrewByIDs(tx *gorm.DB, rows []dutyroster.DeletedCrewResult, now time.Time, updatedBy []byte) error { if len(rows) == 0 { return nil } ids := make([][]byte, 0, len(rows)) for i := range rows { ids = append(ids, rows[i].ID) } return tx.Model(&dutyroster.DutyRosterCrew{}). Where("id IN ? AND deleted_at IS NULL", ids). Updates(map[string]any{ "deleted_at": now, "deleted_by": updatedBy, "updated_by": updatedBy, }).Error }