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

374 lines
12 KiB
Go

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")
}