Files
fm_be/internal/repository/mysql/duty_roster_repo.go
2026-07-16 22:16:45 +07:00

632 lines
20 KiB
Go

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
}