package mysql import ( "context" "errors" "strings" "time" "gorm.io/gorm" flightdata "wucher/internal/domain/flight_data" "wucher/internal/domain/mission" "wucher/internal/shared/pkg/txctx" ) type MissionRepository struct { db *gorm.DB } func NewMissionRepository(db *gorm.DB) *MissionRepository { return &MissionRepository{db: db} } func (r *MissionRepository) Create(ctx context.Context, row *mission.Mission) error { normalizeMissionTimeForDB(row) if err := txctx.DB(ctx, r.db).Create(row).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return mission.ErrDuplicateFlight } return err } return nil } func (r *MissionRepository) WithTransaction(ctx context.Context, fn func(context.Context) error) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return fn(txctx.With(ctx, tx)) }) } // MaxCodeSeqByPrefix returns the highest numeric suffix among mission codes that // start with prefix (e.g. "HEMS-26-"), or 0 when none exist. Used to compute the // next per-type, per-year sequence number. func (r *MissionRepository) MaxCodeSeqByPrefix(ctx context.Context, prefix string) (int, error) { var maxSeq *int err := txctx.DB(ctx, r.db). Model(&mission.Mission{}). Where("code LIKE ? AND deleted_at IS NULL", prefix+"%"). Select("MAX(CAST(SUBSTRING_INDEX(code, '-', -1) AS UNSIGNED))"). Scan(&maxSeq).Error if err != nil { return 0, err } if maxSeq == nil { return 0, nil } return *maxSeq, nil } func (r *MissionRepository) AttachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var missionRow mission.Mission if err := tx.Where("id = ? AND deleted_at IS NULL", missionID). Take(&missionRow).Error; err != nil { return err } var existing mission.MissionFile err := tx.Where("mission_id = ? AND file_attachment_id = ?", missionID, fileAttachmentID). Take(&existing).Error if err == nil { return nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } return tx.Create(&mission.MissionFile{ MissionID: append([]byte(nil), missionID...), FileAttachmentID: append([]byte(nil), fileAttachmentID...), }).Error }) } func (r *MissionRepository) DetachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error { return r.db.WithContext(ctx). Where("mission_id = ? AND file_attachment_id = ?", missionID, fileAttachmentID). Delete(&mission.MissionFile{}).Error } func (r *MissionRepository) CreateCategory(ctx context.Context, row *mission.MissionCategory) error { return r.db.WithContext(ctx).Create(row).Error } func (r *MissionRepository) UpdateFlightDataIDByID(ctx context.Context, missionID, flightDataID, updatedBy []byte) error { patch := map[string]any{ "flight_data_id": flightDataID, } if len(updatedBy) == 16 { patch["updated_by"] = updatedBy } res := txctx.DB(ctx, r.db). Model(&mission.Mission{}). Where("id = ? AND deleted_at IS NULL", missionID). Updates(patch) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *MissionRepository) UpdateByID(ctx context.Context, missionID []byte, missionType string, missionCategoryID []byte, subtypeID []byte, note string, updatedBy []byte) error { patch := map[string]any{ "type": missionType, "mission_category_id": missionCategoryID, "subtype_id": nil, "note": strings.TrimSpace(note), } if len(subtypeID) == 16 { patch["subtype_id"] = subtypeID } if len(updatedBy) == 16 { patch["updated_by"] = updatedBy } res := r.db.WithContext(ctx). Model(&mission.Mission{}). Where("id = ? AND deleted_at IS NULL", missionID). Updates(patch) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *MissionRepository) DeleteByID(ctx context.Context, missionID []byte, deletedBy []byte) error { now := time.Now().UTC() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { patch := map[string]any{ "deleted_at": now, "deleted_by": deletedBy, } if len(deletedBy) == 16 { patch["updated_by"] = deletedBy } res := tx.Model(&mission.Mission{}). Where("id = ? AND deleted_at IS NULL", missionID). Updates(patch) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } fdPatch := map[string]any{ "deleted_at": now, "deleted_by": deletedBy, } if len(deletedBy) == 16 { fdPatch["updated_by"] = deletedBy } if err := tx.Model(&flightdata.FlightData{}). Where("mission_id = ? AND deleted_at IS NULL", missionID). Updates(fdPatch).Error; err != nil { return err } return nil }) } func (r *MissionRepository) DeleteByFlightID(ctx context.Context, flightID []byte, deletedBy []byte) error { now := time.Now().UTC() patch := map[string]any{ "deleted_at": now, "deleted_by": deletedBy, } if len(deletedBy) == 16 { patch["updated_by"] = deletedBy } res := r.db.WithContext(ctx). Model(&mission.Mission{}). Where("flight_id = ? AND deleted_at IS NULL", flightID). Updates(patch) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *MissionRepository) ListByFlightID(ctx context.Context, flightID []byte) ([]mission.Mission, error) { rows := make([]mission.Mission, 0) err := r.db.WithContext(ctx). Select(r.statusSelectSQL()). Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id"). Preload("MissionSubCategory"). Preload("Flight"). Preload("Files"). Preload("Files.FileAttachment"). Preload("Files.FileAttachment.File"). Where("missions.flight_id = ? AND missions.deleted_at IS NULL", flightID). Order("missions.created_at ASC, missions.id ASC"). Find(&rows).Error if err != nil { return nil, err } return rows, nil } func (r *MissionRepository) ListByFlightIDs(ctx context.Context, flightIDs [][]byte) ([]mission.Mission, error) { if len(flightIDs) == 0 { return []mission.Mission{}, nil } rows := make([]mission.Mission, 0) err := r.db.WithContext(ctx). Select(r.statusSelectSQL()). Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id"). Preload("MissionSubCategory"). Preload("Flight"). Preload("Files"). Preload("Files.FileAttachment"). Preload("Files.FileAttachment.File"). Where("missions.flight_id IN ? AND missions.deleted_at IS NULL", flightIDs). Order("missions.flight_id ASC, missions.created_at ASC, missions.id ASC"). Find(&rows).Error if err != nil { return nil, err } return rows, nil } func (r *MissionRepository) GetByID(ctx context.Context, missionID []byte) (*mission.Mission, error) { var row mission.Mission err := r.db.WithContext(ctx). Select(r.statusSelectSQL()). Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id"). Preload("MissionSubCategory"). Preload("Flight"). Preload("Files"). Preload("Files.FileAttachment"). Preload("Files.FileAttachment.File"). Where("missions.id = ? AND missions.deleted_at IS NULL", missionID). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &row, nil } func (r *MissionRepository) GetByFlightID(ctx context.Context, flightID []byte) (*mission.Mission, error) { var row mission.Mission err := r.db.WithContext(ctx). Select(r.statusSelectSQL()). Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id"). Preload("MissionSubCategory"). Preload("Flight"). Preload("Files"). Preload("Files.FileAttachment"). Preload("Files.FileAttachment.File"). Where("missions.flight_id = ? AND missions.deleted_at IS NULL", flightID). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &row, nil } func (r *MissionRepository) List(ctx context.Context, filter, sort string, flightID []byte, flightDataStatus string, limit, offset int) ([]mission.Mission, int64, error) { rows, total, _, err := r.listWithFilter(ctx, mission.ListFilter{ Search: filter, Sort: sort, FlightID: flightID, FlightDataStatus: flightDataStatus, }, limit, offset, false) return rows, total, err } func (r *MissionRepository) ListDatatable(ctx context.Context, filter mission.ListFilter, limit, offset int) ([]mission.Mission, int64, int64, error) { return r.listWithFilter(ctx, filter, limit, offset, true) } func (r *MissionRepository) statusSelectSQL() string { return `missions.*, COALESCE(( SELECT flight_data.id FROM flight_data WHERE flight_data.mission_id = missions.id AND flight_data.deleted_at IS NULL ORDER BY flight_data.created_at DESC, flight_data.id DESC LIMIT 1 ), missions.flight_data_id) AS flight_data_id, ` + r.flightDataStatusExprSQL() + ` AS flight_data_status` } func (r *MissionRepository) flightDataStatusExprSQL() string { completeCondition := `( flight_data.take_off IS NOT NULL AND flight_data.landing IS NOT NULL AND flight_data.duration > 0 AND flight_data.red > 0 AND flight_data.max_n1 > 0 AND flight_data.max_n2 > 0 AND flight_data.pax_count > 0 AND flight_data.landing_count > 0 AND flight_data.rotor_brake_cycle > 0 AND COALESCE(flight_data.ticket_no, '') <> '' AND COALESCE(flight_data.engine, '') <> '' AND COALESCE(flight_data.delivery_note_number, '') <> '' AND COALESCE(flight_data.customer_name, '') <> '' AND flight_data.flight_plan_distance > 0 AND flight_data.flight_plan_time > 0 AND flight_data.flight_plan_true_course > 0 AND flight_data.fuel_before_flight > 0 AND flight_data.fuel_upload > 0 AND flight_data.fuel_after_flight > 0 AND flight_data.fuel_planning > 0 AND COALESCE(flight_data.other_information, '') <> '' )` return `COALESCE(( SELECT CASE WHEN COUNT(flight_data.id) = 0 THEN 'draft' WHEN SUM(CASE WHEN ` + completeCondition + ` THEN 1 ELSE 0 END) = 0 THEN 'draft' WHEN SUM(CASE WHEN ` + completeCondition + ` THEN 1 ELSE 0 END) = COUNT(flight_data.id) THEN 'completed' ELSE 'in_progress' END FROM flight_data WHERE flight_data.mission_id = missions.id AND flight_data.deleted_at IS NULL ), 'draft')` } func (r *MissionRepository) listWithFilter(ctx context.Context, filter mission.ListFilter, limit, offset int, includeUnfilteredTotal bool) ([]mission.Mission, int64, int64, error) { rows := make([]mission.Mission, 0) var total int64 var filteredTotal int64 base := r.db.WithContext(ctx). Model(&mission.Mission{}). Joins("LEFT JOIN flights ON flights.id = missions.flight_id"). Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id"). Where("missions.deleted_at IS NULL") if includeUnfilteredTotal { if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { return nil, 0, 0, err } } else { total = 0 } base = r.applyMissionListFilter(base, filter) countQuery := base.Session(&gorm.Session{}) if err := countQuery.Count(&filteredTotal).Error; err != nil { return nil, 0, 0, err } query := base.Session(&gorm.Session{}). Select(r.statusSelectSQL()). Preload("MissionSubCategory"). Preload("Flight"). Preload("Files"). Preload("Files.FileAttachment"). Preload("Files.FileAttachment.File") if filter.Sort != "" { query = query.Order(filter.Sort) } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, 0, err } if !includeUnfilteredTotal { total = filteredTotal } return rows, total, filteredTotal, nil } func (r *MissionRepository) applyMissionListFilter(query *gorm.DB, filter mission.ListFilter) *gorm.DB { if filter.Search != "" { like := "%" + filter.Search + "%" query = query.Where( "missions.type LIKE ? OR flights.mission_code LIKE ? OR DATE_FORMAT(flights.date, '%Y-%m-%d') LIKE ?", like, like, like, ) } if len(filter.FlightID) == 16 { query = query.Where("missions.flight_id = ?", filter.FlightID) } if filter.FlightDataStatus != "" { query = query.Where(r.flightDataStatusExprSQL()+" = ?", filter.FlightDataStatus) } if filter.MissionType != "" { query = query.Where("missions.type = ?", strings.ToUpper(strings.TrimSpace(filter.MissionType))) } if filter.StartDate != nil { query = query.Where("flights.date >= ?", filter.StartDate.UTC().Format("2006-01-02")) } if filter.EndDate != nil { query = query.Where("flights.date <= ?", filter.EndDate.UTC().Format("2006-01-02")) } if len(filter.PilotID) == 16 { query = query.Where(` EXISTS ( SELECT 1 FROM duty_rosters dr JOIN duty_roster_crews c ON c.roster_id = dr.id WHERE dr.flight_id = missions.flight_id AND dr.deleted_at IS NULL AND c.deleted_at IS NULL AND c.role_code = 'pilot' AND c.user_id = ? )`, filter.PilotID) } if len(filter.HelicopterID) == 16 { query = query.Where(` EXISTS ( SELECT 1 FROM takeover_acs ta JOIN reserve_acs ra ON ra.id = ta.reserve_ac_id WHERE ta.id = flights.takeover_ac_id AND ta.deleted_at IS NULL AND ra.deleted_at IS NULL AND ra.helicopter_id = ? )`, filter.HelicopterID) } return query } func normalizeMissionTimeForDB(row *mission.Mission) { if row == nil { return } row.StartTime = normalizeMissionDBClock(row.StartTime) row.EndTime = normalizeMissionDBClock(row.EndTime) } func normalizeMissionDBClock(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } if len(trimmed) == 5 { return trimmed + ":00" } if len(trimmed) == 8 { return trimmed } if parsed, err := time.Parse("15:04", trimmed); err == nil { return parsed.Format("15:04:05") } if parsed, err := time.Parse("15:04:05", trimmed); err == nil { return parsed.Format("15:04:05") } return trimmed } func (r *MissionRepository) ListCategoriesWithSubCategories(ctx context.Context) ([]mission.MissionCategory, error) { var rows []mission.MissionCategory if err := r.db.WithContext(ctx). Preload("SubCategory", func(db *gorm.DB) *gorm.DB { return db.Order("sub_type ASC") }). Order("code_type ASC"). Find(&rows).Error; err != nil { return nil, err } for i := range rows { rows[i].CodeType = strings.TrimSpace(rows[i].CodeType) rows[i].TypeName = strings.TrimSpace(rows[i].TypeName) for j := range rows[i].SubCategory { rows[i].SubCategory[j].SubCodeType = strings.TrimSpace(rows[i].SubCategory[j].SubCodeType) rows[i].SubCategory[j].SubTypeName = strings.TrimSpace(rows[i].SubCategory[j].SubTypeName) rows[i].SubCategory[j].TaskName = strings.TrimSpace(rows[i].SubCategory[j].TaskName) } } return rows, nil } func (r *MissionRepository) FindCategoryByCode(ctx context.Context, code string) (*mission.MissionCategory, error) { var row mission.MissionCategory err := r.db.WithContext(ctx). Where("code_type = ?", code). Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &row, nil } func (r *MissionRepository) SubCategoryBelongsToCategory(ctx context.Context, subCategoryID, categoryID []byte) (bool, error) { if len(subCategoryID) != 16 || len(categoryID) != 16 { return false, nil } var cnt int64 if err := r.db.WithContext(ctx). Table("mission_subcategories"). Where("id = ? AND mission_category_id = ?", subCategoryID, categoryID). Count(&cnt).Error; err != nil { return false, err } return cnt > 0, nil }