package mysql import ( "context" "errors" "fmt" "strconv" "strings" "gorm.io/gorm" "gorm.io/gorm/clause" "wucher/internal/domain/helicopter" "wucher/internal/shared/pkg/sortkey" ) type HelicopterRepository struct { db *gorm.DB hasUsersTable bool } func NewHelicopterRepository(db *gorm.DB) *HelicopterRepository { schema := newSchemaCache(db) return &HelicopterRepository{ db: db, hasUsersTable: schema.HasTable("users"), } } func (r *HelicopterRepository) Create(ctx context.Context, h *helicopter.Helicopter) error { requestedIsActive := h.IsActive db := r.db.WithContext(ctx) if err := db.Create(h).Error; err != nil { return err } return db.Model(&helicopter.Helicopter{}).Where("id = ?", h.ID).UpdateColumn("is_active", requestedIsActive).Error } func (r *HelicopterRepository) CreateWithDB(ctx context.Context, db *gorm.DB, h *helicopter.Helicopter) error { requestedIsActive := h.IsActive if err := db.WithContext(ctx).Create(h).Error; err != nil { return err } return db.WithContext(ctx).Model(&helicopter.Helicopter{}).Where("id = ?", h.ID).UpdateColumn("is_active", requestedIsActive).Error } func (r *HelicopterRepository) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { return r.db.WithContext(ctx).Transaction(fn) } func (r *HelicopterRepository) Update(ctx context.Context, h *helicopter.Helicopter) error { // Persist only helicopter columns; avoid association auto-save side effects // when handler payload carries preloaded FotoAttachment relation. return r.db.WithContext(ctx).Omit(clause.Associations).Save(h).Error } func (r *HelicopterRepository) ExistsByIdentifier(ctx context.Context, identifier string, excludeID []byte) (bool, error) { db := r.db.WithContext(ctx).Model(&helicopter.Helicopter{}). Where("UPPER(TRIM(identifier)) = ?", strings.ToUpper(strings.TrimSpace(identifier))) if len(excludeID) == 16 { db = db.Where("id <> ?", excludeID) } var total int64 if err := db.Count(&total).Error; err != nil { return false, err } return total > 0, nil } func (r *HelicopterRepository) Delete(ctx context.Context, id []byte) error { if err := ensureNoReferenceBeforeDelete(ctx, r.db, "helicopters", id); err != nil { return err } return mapDeleteConstraintError(r.db.WithContext(ctx).Delete(&helicopter.Helicopter{}, "id = ?", id).Error) } func (r *HelicopterRepository) GetByID(ctx context.Context, id []byte) (*helicopter.Helicopter, error) { var h helicopter.Helicopter err := r.withAuditUsers(r.db.WithContext(ctx)). Preload("FotoAttachment"). Preload("FotoAttachment.File"). Where("helicopters.id = ?", id). First(&h).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &h, err } func (r *HelicopterRepository) List(ctx context.Context, filter string, statuses []string, sort string, limit, offset int, groundedIDs [][]byte) ([]helicopter.Helicopter, int64, error) { var helicopters []helicopter.Helicopter var total int64 base := r.withAuditUsers(r.db.WithContext(ctx)) if filter != "" { like := "%" + filter + "%" base = base.Where("helicopters.designation LIKE ? OR helicopters.identifier LIKE ? OR helicopters.type LIKE ?", like, like, like) } base = r.applyStatusFilter(base, statuses, groundedIDs) query := base if sort != "" { query = query.Order(sort) } else { for _, clause := range sortkey.ActivePositiveSortClauses("helicopters", "is_active", "sortkey", "designation", false) { query = query.Order(clause) } } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } query = query.Preload("FotoAttachment").Preload("FotoAttachment.File") if err := query.Find(&helicopters).Error; err != nil { return nil, 0, err } return helicopters, total, nil } // applyStatusFilter restricts query to helicopters whose derived operational // status is in statuses. The conditions mirror helicopter.DeriveStatus: AOG // (air_on_ground) outranks an active flight assignment ("booked"), and // "available" means neither; "mcf" has no data source yet, so it matches // nothing. Multiple statuses are OR-ed together. Returns query unchanged when // no status filter applies. func (r *HelicopterRepository) applyStatusFilter(query *gorm.DB, statuses []string, groundedIDs [][]byte) *gorm.DB { if len(statuses) == 0 { return query } // inUse mirrors IsInUse: the helicopter has a non-deleted flight assignment // reachable through takeover_acs.reserve_ac_id. A fresh subquery is built // per use to avoid sharing builder state. inUse := func() *gorm.DB { return r.db. Table("reserve_acs ra"). Select("1"). Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL"). Joins("JOIN flights f ON f.takeover_ac_id = ta.id"). Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id"). Where("ra.helicopter_id = helicopters.id"). Where("ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL") } // A helicopter is AOG when its persisted air_on_ground flag is set OR it is // grounded by an open complaint (computed in Go from IsGrounding and passed in via // groundedIDs, since the read-time grounding is not persisted in air_on_ground). hasGrounded := len(groundedIDs) > 0 group := r.db.Session(&gorm.Session{NewDB: true}) seen := make(map[string]struct{}, len(statuses)) for _, status := range statuses { if _, ok := seen[status]; ok { continue } seen[status] = struct{}{} switch status { case helicopter.StatusAOG: aog := r.db.Where("helicopters.air_on_ground = ?", true) if hasGrounded { aog = aog.Or("helicopters.id IN ?", groundedIDs) } group = group.Or(aog) case helicopter.StatusBooked: booked := r.db.Where("helicopters.air_on_ground = ?", false).Where("EXISTS (?)", inUse()) if hasGrounded { booked = booked.Where("helicopters.id NOT IN ?", groundedIDs) } group = group.Or(booked) case helicopter.StatusAvailable: avail := r.db.Where("helicopters.air_on_ground = ?", false).Where("NOT EXISTS (?)", inUse()) if hasGrounded { avail = avail.Where("helicopters.id NOT IN ?", groundedIDs) } group = group.Or(avail) case helicopter.StatusMCF: group = group.Or("1 = 0") // no data source yet } } return query.Where(group) } func (r *HelicopterRepository) NextReportNumber(ctx context.Context, id []byte) (string, int, error) { var reportNumber string var sequence int err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var h helicopter.Helicopter if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&h).Error; err != nil { return err } identifier := strings.TrimSpace(h.Identifier) if identifier == "" { return errors.New("helicopter identifier is empty") } if base, _, ok := splitIdentifierBase(identifier); ok { identifier = base } next := h.ReportSequence + 1 if err := tx.Model(&helicopter.Helicopter{}).Where("id = ?", h.ID).Update("report_sequence", next).Error; err != nil { return err } sequence = next reportNumber = fmt.Sprintf("%s-%04d", identifier, next) return nil }) if err != nil { return "", 0, err } return reportNumber, sequence, nil } func (r *HelicopterRepository) IsInUse(ctx context.Context, id []byte) (bool, error) { if len(id) != 16 { return false, nil } var total int64 err := r.db.WithContext(ctx). Table("reserve_acs ra"). Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL"). Joins("JOIN flights f ON f.takeover_ac_id = ta.id"). Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id"). Where("ra.helicopter_id = ? AND ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL", id). Count(&total).Error return total > 0, err } func (r *HelicopterRepository) InUseByAircraftIDs(ctx context.Context, ids [][]byte) (map[string]bool, error) { out := make(map[string]bool) if len(ids) == 0 { return out, nil } filtered := make([][]byte, 0, len(ids)) seen := make(map[string]struct{}, len(ids)) for _, id := range ids { if len(id) != 16 { continue } key := string(id) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} filtered = append(filtered, append([]byte(nil), id...)) } if len(filtered) == 0 { return out, nil } type row struct { HelicopterID []byte `gorm:"column:helicopter_id"` } rows := make([]row, 0) if err := r.db.WithContext(ctx). Table("reserve_acs ra"). Select("DISTINCT ra.helicopter_id"). Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL"). Joins("JOIN flights f ON f.takeover_ac_id = ta.id"). Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id"). Where("ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL"). Where("ra.helicopter_id IN ?", filtered). Scan(&rows).Error; err != nil { return nil, err } for i := range rows { out[string(rows[i].HelicopterID)] = true } return out, nil } // ActiveFlightIDByAircraftIDs returns, per helicopter, the id of its current active flight // (a takeover flight with no after-flight inspection yet). Mirrors the InUseByAircraftIDs // join so "in use" and "which flight" stay consistent. Only in-use helicopters appear in // the map; the newest flight wins if several are open. Used by the fleet status page to // create an EASA release scoped to the flight. func (r *HelicopterRepository) ActiveFlightIDByAircraftIDs(ctx context.Context, ids [][]byte) (map[string][]byte, error) { out := make(map[string][]byte) if len(ids) == 0 { return out, nil } filtered := make([][]byte, 0, len(ids)) seen := make(map[string]struct{}, len(ids)) for _, id := range ids { if len(id) != 16 { continue } key := string(id) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} filtered = append(filtered, append([]byte(nil), id...)) } if len(filtered) == 0 { return out, nil } type row struct { HelicopterID []byte `gorm:"column:helicopter_id"` FlightID []byte `gorm:"column:flight_id"` } rows := make([]row, 0) if err := r.db.WithContext(ctx). Table("reserve_acs ra"). Select("ra.helicopter_id AS helicopter_id, f.id AS flight_id"). Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL"). Joins("JOIN flights f ON f.takeover_ac_id = ta.id"). Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id"). Where("ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL"). Where("ra.helicopter_id IN ?", filtered). Order("f.created_at DESC"). Scan(&rows).Error; err != nil { return nil, err } for i := range rows { k := string(rows[i].HelicopterID) if _, ok := out[k]; !ok && len(rows[i].FlightID) == 16 { out[k] = rows[i].FlightID } } return out, nil } func splitIdentifierBase(raw string) (string, int, bool) { s := strings.TrimSpace(raw) idx := strings.LastIndex(s, "-") if idx <= 0 || idx+5 != len(s) { return "", 0, false } base := strings.TrimSpace(s[:idx]) if base == "" { return "", 0, false } seq, err := strconv.Atoi(s[idx+1:]) if err != nil || seq <= 0 { return "", 0, false } return base, seq, true } func (r *HelicopterRepository) withAuditUsers(db *gorm.DB) *gorm.DB { base := db.Model(&helicopter.Helicopter{}) if !r.hasUsersTable { return base.Select("helicopters.*, '' AS created_by_name, '' AS updated_by_name") } return base. Select(` helicopters.*, COALESCE( NULLIF(TRIM(CONCAT(COALESCE(cu.first_name, ''), ' ', COALESCE(cu.last_name, ''))), ''), NULLIF(TRIM(cu.username), ''), NULLIF(TRIM(cu.email), ''), '' ) AS created_by_name, COALESCE( NULLIF(TRIM(CONCAT(COALESCE(uu.first_name, ''), ' ', COALESCE(uu.last_name, ''))), ''), NULLIF(TRIM(uu.username), ''), NULLIF(TRIM(uu.email), ''), '' ) AS updated_by_name `). Joins("LEFT JOIN users cu ON cu.id = helicopters.created_by"). Joins("LEFT JOIN users uu ON uu.id = helicopters.updated_by") }