Files
fm_be/internal/service/takeover_service.go
2026-07-16 22:16:45 +07:00

1648 lines
55 KiB
Go

package service
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
basedomain "wucher/internal/domain/base"
beforeflightinspection "wucher/internal/domain/before_flight_inspection"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/domain/flight"
flightinspection "wucher/internal/domain/flight_inspection"
flightinspectionfilechecklist "wucher/internal/domain/flight_inspection_file_checklist"
flightprepcheck "wucher/internal/domain/flight_prep_check"
helicopterfile "wucher/internal/domain/helicopter_file"
otherpersondomain "wucher/internal/domain/other_person"
reserveac "wucher/internal/domain/reserve_ac"
takeoverdomain "wucher/internal/domain/takeover"
"wucher/internal/shared/pkg/missioncode"
"wucher/internal/shared/pkg/reserveacutil"
"wucher/internal/shared/pkg/util"
"wucher/internal/shared/pkg/uuidv7"
requestdto "wucher/internal/transport/http/dto/request"
)
// checklistTemplateFileRepository loads the helicopter files that make up an inspection
// checklist section. The concrete implementation (mysql.TakeoverRepository) is injected so
// the service does not depend on the repository package directly. The transaction handle is
// passed through so the read runs within the caller's ongoing transaction.
type checklistTemplateFileRepository interface {
ListChecklistTemplateFiles(tx *gorm.DB, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error)
ListActiveEditedTemplateUUIDs(tx *gorm.DB, takeoverID []byte) ([][]byte, error)
}
type TakeoverService struct {
db *gorm.DB
checklistFiles checklistTemplateFileRepository
}
type TakeoverCreateInput struct {
BaseID []byte
HasBaseID bool
DutyDate time.Time
HasDutyDate bool
Notes *string
RosterDetail *requestdto.DutyRosterDetailsInput
HasRosterDetail bool
HelicopterID []byte
HasHelicopterID bool
Inspection *requestdto.ReserveAcInspectionCreateAttributes
HasInspection bool
ActorUserID []byte
}
type TakeoverCreateResult struct {
TakeoverAcID []byte
DutyRosterID []byte
FlightID []byte
ReserveAcID []byte
FlightInspectionID []byte
ShiftStart string
ShiftEnd string
}
type takeoverExisting struct {
Flight *flight.Flight
Takeover *takeoverdomain.TakeoverAc
Reserve *reserveac.ReserveAc
Crews []takeoverdomain.TakeoverRosterCrew
Others []takeoverdomain.TakeoverOtherPerson
Before *beforeflightinspection.BeforeFlightInspection
Prep *flightprepcheck.FlightPrepCheck
}
func NewTakeoverService(db *gorm.DB) *TakeoverService {
return &TakeoverService{db: db}
}
// WithChecklistTemplateRepository injects the repository used to load checklist template
// files. It must be wired at startup so DB access stays in the repository layer.
func (s *TakeoverService) WithChecklistTemplateRepository(repo checklistTemplateFileRepository) *TakeoverService {
if s != nil {
s.checklistFiles = repo
}
return s
}
func logTakeoverServiceStepError(step string, err error) {
if err == nil {
return
}
slog.Error("takeover service step failed",
"step", step,
"error", err.Error(),
"error_type", fmt.Sprintf("%T", err),
)
}
func (s *TakeoverService) Create(ctx context.Context, in TakeoverCreateInput) (*TakeoverCreateResult, error) {
if s == nil || s.db == nil {
return nil, errors.New("takeover service is not configured")
}
if !in.HasDutyDate {
return nil, errors.New("duty_date is required")
}
if in.HasBaseID && len(in.BaseID) != 16 {
return nil, errors.New("base_id must be a valid UUID")
}
if in.HasHelicopterID && len(in.HelicopterID) != 16 {
return nil, errors.New("helicopter_id must be a valid UUID")
}
if !in.HasBaseID && !in.HasRosterDetail {
return nil, errors.New("base_id or roster_detail is required")
}
return s.createInTx(ctx, in)
}
func (s *TakeoverService) createInTx(ctx context.Context, in TakeoverCreateInput) (*TakeoverCreateResult, error) {
result := &TakeoverCreateResult{}
err := s.withRetryTx(ctx, func(tx *gorm.DB) error {
if in.HasHelicopterID {
booked, err := isHelicopterBookedTx(tx, in.HelicopterID, nil)
if err != nil {
return err
}
if booked {
return errors.New("helicopter is already booked")
}
}
takeoverAc := &takeoverdomain.TakeoverAc{
Notes: in.Notes,
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if in.HasBaseID {
shiftStart, shiftEnd, err := ResolveBaseShiftRangeTx(tx, in.BaseID, in.DutyDate)
if err != nil {
return err
}
result.ShiftStart = shiftStart
result.ShiftEnd = shiftEnd
if err := ensureBaseExistsTx(tx, in.BaseID); err != nil {
return err
}
takeoverAc.BaseID = append([]byte(nil), in.BaseID...)
}
if err := tx.Create(takeoverAc).Error; err != nil {
return err
}
result.TakeoverAcID = append([]byte(nil), takeoverAc.ID...)
if in.HasRosterDetail {
if err := s.upsertTakeoverRosterRowsTx(tx, takeoverAc.ID, in); err != nil {
return err
}
}
existing := &takeoverExisting{}
if err := s.upsertTakeoverReserveTx(tx, takeoverAc.ID, in.BaseID, existing, in); err != nil {
return err
}
now := time.Now().UTC()
prefix := missioncode.BuildPrefix(now)
latest, err := getLatestMissionCodeByPrefixTx(tx, prefix)
if err != nil {
return err
}
nextCode := missioncode.NextFromLatest(prefix, latest)
flightRow := &flight.Flight{
Status: "draft",
MissionCode: &nextCode,
Date: dateOnlyUTC(in.DutyDate),
TakeoverAcID: append([]byte(nil), takeoverAc.ID...),
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if err := tx.Create(flightRow).Error; err != nil {
return err
}
result.FlightID = append([]byte(nil), flightRow.ID...)
if existing.Reserve != nil {
result.ReserveAcID = append([]byte(nil), existing.Reserve.ID...)
result.FlightInspectionID = append([]byte(nil), existing.Reserve.InspectionID...)
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *TakeoverService) Replace(ctx context.Context, existingFlightID []byte, in TakeoverCreateInput) (*TakeoverCreateResult, error) {
if len(existingFlightID) != 16 {
return nil, errors.New("id is invalid UUID")
}
result := &TakeoverCreateResult{}
err := s.withRetryTx(ctx, func(tx *gorm.DB) error {
existing, err := loadTakeoverExistingTx(tx, existingFlightID)
if err != nil {
logTakeoverServiceStepError("Replace.loadTakeoverExistingTx", err)
return err
}
if existing == nil || existing.Flight == nil {
return gorm.ErrRecordNotFound
}
targetBaseID := []byte(nil)
if existing.Reserve != nil && len(existing.Reserve.BaseID) > 0 {
targetBaseID = append([]byte(nil), existing.Reserve.BaseID...)
} else if existing.Takeover != nil && len(existing.Takeover.BaseID) > 0 {
targetBaseID = append([]byte(nil), existing.Takeover.BaseID...)
}
if in.HasBaseID {
if len(in.BaseID) != 16 {
return errors.New("base_id must be a valid UUID")
}
if err := ensureBaseExistsTx(tx, in.BaseID); err != nil {
logTakeoverServiceStepError("Replace.ensureBaseExistsTx", err)
return err
}
targetBaseID = append([]byte(nil), in.BaseID...)
}
effectiveDutyDate := existing.Flight.Date
if in.HasDutyDate {
effectiveDutyDate = dateOnlyUTC(in.DutyDate)
}
if len(targetBaseID) > 0 {
shiftStart, shiftEnd, err := ResolveBaseShiftRangeTx(tx, targetBaseID, effectiveDutyDate)
if err != nil {
logTakeoverServiceStepError("Replace.ResolveBaseShiftRangeTx", err)
return err
}
result.ShiftStart = shiftStart
result.ShiftEnd = shiftEnd
}
takeoverID := existing.Flight.TakeoverAcID
if len(takeoverID) != 16 {
return gorm.ErrRecordNotFound
}
currentHelicopterID := []byte(nil)
if existing.Reserve != nil && len(existing.Reserve.AircraftID) == 16 {
currentHelicopterID = append([]byte(nil), existing.Reserve.AircraftID...)
}
if in.HasHelicopterID && len(in.HelicopterID) == 16 && !bytes.Equal(in.HelicopterID, currentHelicopterID) {
booked, err := isHelicopterBookedTx(tx, in.HelicopterID, takeoverID)
if err != nil {
logTakeoverServiceStepError("Replace.isHelicopterBookedTx", err)
return err
}
if booked {
return errors.New("helicopter is already booked")
}
}
takeoverUpdates := map[string]any{"base_id": targetBaseID, "updated_by": in.ActorUserID}
if in.Notes != nil {
takeoverUpdates["notes"] = *in.Notes
}
if err := tx.Model(&takeoverdomain.TakeoverAc{}).
Where("id = ? AND deleted_at IS NULL", takeoverID).
Updates(takeoverUpdates).Error; err != nil {
logTakeoverServiceStepError("Replace.updateTakeover", err)
return err
}
if err := s.upsertTakeoverReserveTx(tx, takeoverID, targetBaseID, existing, in); err != nil {
logTakeoverServiceStepError("Replace.upsertTakeoverReserveTx", err)
return err
}
targetFlightDate := dateOnlyUTC(in.DutyDate)
if in.HasDutyDate && !existing.Flight.Date.Equal(targetFlightDate) {
existing.Flight.Date = targetFlightDate
if err := tx.Save(existing.Flight).Error; err != nil {
logTakeoverServiceStepError("Replace.saveFlightDate", err)
return err
}
}
if in.HasRosterDetail {
if err := s.upsertTakeoverRosterRowsTx(tx, takeoverID, in); err != nil {
logTakeoverServiceStepError("Replace.upsertTakeoverRosterRowsTx", err)
return err
}
}
if existing.Reserve != nil {
result.ReserveAcID = append([]byte(nil), existing.Reserve.ID...)
result.FlightInspectionID = append([]byte(nil), existing.Reserve.InspectionID...)
}
result.TakeoverAcID = append([]byte(nil), takeoverID...)
result.FlightID = append([]byte(nil), existing.Flight.ID...)
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *TakeoverService) GetByID(ctx context.Context, id []byte) (*takeoverdomain.TakeoverAc, error) {
if s == nil || s.db == nil {
return nil, errors.New("takeover service is not configured")
}
if len(id) != 16 {
return nil, errors.New("id is invalid UUID")
}
var row takeoverdomain.TakeoverAc
err := s.db.WithContext(ctx).
Preload("Base").
Preload("ReserveAc").
Preload("ReserveAc.Aircraft").
Preload("ReserveAc.Inspection").
Preload("RosterCrews").
Preload("RosterCrews.User").
Preload("OtherPeople").
Preload("Files").
Preload("Files.FileAttachment").
Preload("Files.FileAttachment.File").
Where("id = ? AND deleted_at IS NULL", id).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &row, err
}
func (s *TakeoverService) List(ctx context.Context, limit, offset int) ([]takeoverdomain.TakeoverAc, int64, error) {
if s == nil || s.db == nil {
return nil, 0, errors.New("takeover service is not configured")
}
rows := make([]takeoverdomain.TakeoverAc, 0)
var total int64
base := s.db.WithContext(ctx).
Model(&takeoverdomain.TakeoverAc{}).
Preload("Base").
Preload("ReserveAc").
Preload("ReserveAc.Aircraft").
Preload("ReserveAc.Inspection").
Preload("RosterCrews").
Preload("OtherPeople").
Preload("Files").
Preload("Files.FileAttachment").
Preload("Files.FileAttachment.File").
Where("deleted_at IS NULL")
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
q := base.Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit).Offset(offset)
}
if err := q.Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (s *TakeoverService) AttachFile(ctx context.Context, takeoverID []byte, fileAttachmentID []byte) error {
if s == nil || s.db == nil {
return errors.New("takeover service is not configured")
}
if len(takeoverID) != 16 {
return errors.New("takeover_id must be a valid UUID")
}
if len(fileAttachmentID) != 16 {
return errors.New("file_attachment_id must be a valid UUID")
}
return s.withRetryTx(ctx, func(tx *gorm.DB) error {
return attachTakeoverFilesTx(tx, takeoverID, [][]byte{fileAttachmentID})
})
}
func (s *TakeoverService) DetachFile(ctx context.Context, takeoverID []byte, fileAttachmentID []byte) error {
if s == nil || s.db == nil {
return errors.New("takeover service is not configured")
}
if len(takeoverID) != 16 {
return errors.New("takeover_id must be a valid UUID")
}
if len(fileAttachmentID) != 16 {
return errors.New("file_attachment_id must be a valid UUID")
}
return s.withRetryTx(ctx, func(tx *gorm.DB) error {
var takeoverRow takeoverdomain.TakeoverAc
if err := tx.Where("id = ? AND deleted_at IS NULL", takeoverID).Take(&takeoverRow).Error; err != nil {
return err
}
res := tx.Where("takeover_id = ? AND file_attachment_id = ?", takeoverID, fileAttachmentID).Delete(&takeoverdomain.TakeoverFile{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func attachTakeoverFilesTx(tx *gorm.DB, takeoverID []byte, fileAttachmentIDs [][]byte) error {
if len(takeoverID) != 16 {
return errors.New("takeover_id must be a valid UUID")
}
var takeoverRow takeoverdomain.TakeoverAc
if err := tx.Where("id = ? AND deleted_at IS NULL", takeoverID).Take(&takeoverRow).Error; err != nil {
return err
}
for i := range fileAttachmentIDs {
fileAttachmentID := fileAttachmentIDs[i]
if len(fileAttachmentID) != 16 {
return errors.New("file_attachment_id must be a valid UUID")
}
var attachment filemanager.Attachment
if err := tx.Where("id = ?", fileAttachmentID).Take(&attachment).Error; err != nil {
return err
}
var existing takeoverdomain.TakeoverFile
err := tx.Where("takeover_id = ? AND file_attachment_id = ?", takeoverID, fileAttachmentID).Take(&existing).Error
if err == nil {
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
row := &takeoverdomain.TakeoverFile{
TakeoverID: append([]byte(nil), takeoverID...),
FileAttachmentID: append([]byte(nil), fileAttachmentID...),
}
if err := tx.Create(row).Error; err != nil {
return err
}
}
return nil
}
func (s *TakeoverService) withRetryTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
const maxAttempts = 2
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastErr = s.db.WithContext(ctx).Transaction(fn)
if lastErr == nil {
return nil
}
if !isRetryableDBErr(lastErr) || attempt == maxAttempts {
return lastErr
}
}
return lastErr
}
func isRetryableDBErr(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "bad connection") ||
strings.Contains(msg, "invalid connection") ||
strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "broken pipe") ||
strings.Contains(msg, "driver: bad connection")
}
func loadTakeoverExistingTx(tx *gorm.DB, flightID []byte) (*takeoverExisting, error) {
var fl flight.Flight
if err := tx.Where("id = ? AND deleted_at IS NULL", flightID).Take(&fl).Error; err != nil {
return nil, err
}
var takeoverRow takeoverdomain.TakeoverAc
if err := tx.Where("id = ? AND deleted_at IS NULL", fl.TakeoverAcID).Take(&takeoverRow).Error; err != nil {
return nil, err
}
crews, err := loadTakeoverRosterCrewsTx(tx, takeoverRow.ID)
if err != nil {
return nil, err
}
others, err := loadTakeoverOtherPeopleTx(tx, takeoverRow.ID)
if err != nil {
return nil, err
}
var reserve *reserveac.ReserveAc
if len(takeoverRow.ReserveAcID) == 16 {
var reserveRow reserveac.ReserveAc
if err := tx.Where("id = ? AND deleted_at IS NULL", takeoverRow.ReserveAcID).Take(&reserveRow).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
} else if err == nil {
reserve = &reserveRow
}
}
var beforeRow *beforeflightinspection.BeforeFlightInspection
var prepRow *flightprepcheck.FlightPrepCheck
if reserve != nil && len(reserve.InspectionID) == 16 {
var before beforeflightinspection.BeforeFlightInspection
if err := tx.Where("flight_inspection_id = ?", reserve.InspectionID).Take(&before).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
} else if err == nil {
beforeRow = &before
}
var prep flightprepcheck.FlightPrepCheck
if err := tx.Where("flight_inspection_id = ?", reserve.InspectionID).Take(&prep).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
} else if err == nil {
prepRow = &prep
}
}
return &takeoverExisting{Flight: &fl, Takeover: &takeoverRow, Reserve: reserve, Crews: crews, Others: others, Before: beforeRow, Prep: prepRow}, nil
}
func dutyDateTimeString(dutyDate time.Time, clock string) string {
trimmed := strings.TrimSpace(clock)
if len(trimmed) == 5 {
trimmed += ":00"
}
return dutyDate.UTC().Format("2006-01-02") + " " + trimmed
}
func ensureBaseExistsTx(tx *gorm.DB, id []byte) error {
var count int64
if err := tx.Table("bases").Where("id = ? AND deleted_at IS NULL", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("base_id does not exist")
}
return nil
}
func ensureHelicopterExistsTx(tx *gorm.DB, id []byte) error {
var count int64
if err := tx.Table("helicopters").Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("helicopter_id does not exist")
}
return nil
}
func ResolveBaseShiftRangeTx(tx *gorm.DB, baseID []byte, dutyDate time.Time) (string, string, error) {
if len(baseID) != 16 {
return "", "", errors.New("base_id does not exist")
}
var baseRow basedomain.Base
err := tx.Preload("OperationalShiftTimes", func(db *gorm.DB) *gorm.DB {
return db.Where("deleted_at IS NULL").Order("date_start ASC, date_end ASC")
}).
Where("id = ? AND deleted_at IS NULL", baseID).
First(&baseRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", errors.New("base_id does not exist")
}
if err != nil {
return "", "", err
}
return util.ResolveShiftWindow(&baseRow, dutyDate)
}
func isHelicopterBookedTx(tx *gorm.DB, helicopterID []byte, excludeTakeoverID []byte) (bool, error) {
if len(helicopterID) != 16 {
return false, nil
}
q := tx.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", helicopterID)
if len(excludeTakeoverID) == 16 {
q = q.Where("ta.id <> ?", excludeTakeoverID)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return false, err
}
return total > 0, nil
}
func ensureUserExistsTx(tx *gorm.DB, id []byte) error {
var count int64
if err := tx.Table("users").Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("one or more crew contact ids do not exist")
}
return nil
}
func (s *TakeoverService) upsertTakeoverChecklistItemsTx(tx *gorm.DB, inspectionID, helicopterID []byte, section string, items []requestdto.ReserveAcFileChecklistItem, actor []byte) error {
if s == nil || s.checklistFiles == nil {
return errors.New("checklist template repository is not configured")
}
templateFiles, err := s.checklistFiles.ListChecklistTemplateFiles(tx, helicopterID, section)
if err != nil {
return err
}
if len(templateFiles) == 0 {
if len(items) == 0 {
return nil
}
return fmt.Errorf("inspection checklist template is empty for section %s", section)
}
if len(items) == 0 {
return fmt.Errorf("%s is required", takeoverChecklistSectionLabel(section))
}
payloadByFileID := make(map[string]bool, len(items))
for i := range items {
if len(items[i].HelicopterFileID) == 0 {
return errors.New("helicopter_file_id is required")
}
hfID := strings.TrimSpace(items[i].HelicopterFileID)
parsedHFID, err := uuidv7.ParseString(hfID)
if err != nil {
return errors.New("invalid helicopter_file_id")
}
fileKey := string(parsedHFID)
if _, exists := payloadByFileID[fileKey]; exists {
return fmt.Errorf("duplicate helicopter_file_id in %s", takeoverChecklistSectionLabel(section))
}
payloadByFileID[fileKey] = items[i].IsDone
}
templateSet := make(map[string]struct{}, len(templateFiles))
for i := range templateFiles {
templateSet[string(templateFiles[i].ID)] = struct{}{}
}
for fileKey := range payloadByFileID {
if _, ok := templateSet[fileKey]; ok {
continue
}
return fmt.Errorf("helicopter_file_id does not belong to selected helicopter/section")
}
for i := range templateFiles {
fileKey := string(templateFiles[i].ID)
isDone, ok := payloadByFileID[fileKey]
if !ok {
// Only the files the user actually submitted are recorded. A catalog
// file that was not edited need not be included in the checklist
// (even if it is mandatory) — validateTakeoverEditedTemplateFilesTx
// separately guarantees every edited file IS included.
continue
}
if templateFiles[i].IsMandatory && !isDone {
return fmt.Errorf("%s must be fully checked", takeoverChecklistSectionLabel(section))
}
row := &flightinspectionfilechecklist.FlightInspectionFileChecklist{
HelicopterFileID: templateFiles[i].ID,
FlightInspectionID: inspectionID,
IsDone: isDone,
CreatedBy: actor,
UpdatedBy: actor,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "flight_inspection_id"}, {Name: "helicopter_file_id"}},
DoUpdates: clause.AssignmentColumns([]string{"is_done", "updated_by"}),
}).Create(row).Error; err != nil {
return err
}
}
return nil
}
// validateTakeoverEditedTemplateFilesTx enforces that every file that was
// created/edited from an FPWB template for this takeover is included in the
// submitted flight-preparation file checklist. A file that was not edited does
// not need to be in the checklist.
//
// New data stores template_uuid == HelicopterFile.ID. Legacy data may instead
// store the underlying source file ID. We accept both by resolving active FPWB
// checklist templates to a canonical helicopter_file_id before validating.
// Orphaned historical references that no longer map to an active checklist
// template are ignored so stale data cannot block takeover updates forever.
func (s *TakeoverService) validateTakeoverEditedTemplateFilesTx(tx *gorm.DB, takeoverID, helicopterID []byte, items []requestdto.ReserveAcFileChecklistItem) error {
if len(takeoverID) != 16 {
return nil
}
if s == nil || s.checklistFiles == nil {
return errors.New("checklist template repository is not configured")
}
editedTemplateUUIDs, err := s.checklistFiles.ListActiveEditedTemplateUUIDs(tx, takeoverID)
if err != nil {
return err
}
if len(editedTemplateUUIDs) == 0 {
return nil
}
templateFiles, err := s.checklistFiles.ListChecklistTemplateFiles(tx, helicopterID, helicopterfile.SectionFlightPreparationAndWB)
if err != nil {
return err
}
templateReferenceToChecklistID := make(map[string]string, len(templateFiles)*3)
for i := range templateFiles {
if len(templateFiles[i].ID) == 16 {
canonicalID := string(templateFiles[i].ID)
templateReferenceToChecklistID[canonicalID] = canonicalID
if len(templateFiles[i].SourceFileID) == 16 {
templateReferenceToChecklistID[string(templateFiles[i].SourceFileID)] = canonicalID
}
if templateFiles[i].SourceFile != nil && len(templateFiles[i].SourceFile.ID) == 16 {
templateReferenceToChecklistID[string(templateFiles[i].SourceFile.ID)] = canonicalID
}
if templateFiles[i].FileAttachment != nil && templateFiles[i].FileAttachment.File != nil && len(templateFiles[i].FileAttachment.File.ID) == 16 {
templateReferenceToChecklistID[string(templateFiles[i].FileAttachment.File.ID)] = canonicalID
}
}
}
checklistSet := make(map[string]struct{}, len(items))
for i := range items {
hfID := strings.TrimSpace(items[i].HelicopterFileID)
if hfID == "" {
continue
}
parsedHFID, err := uuidv7.ParseString(hfID)
if err != nil {
return errors.New("invalid helicopter_file_id")
}
checklistSet[string(parsedHFID)] = struct{}{}
}
for i := range editedTemplateUUIDs {
if len(editedTemplateUUIDs[i]) != 16 {
continue
}
requiredChecklistID, ok := templateReferenceToChecklistID[string(editedTemplateUUIDs[i])]
if !ok {
continue
}
if _, ok := checklistSet[requiredChecklistID]; !ok {
return fmt.Errorf("edited template file must be included in the flight preparation file checklist")
}
}
return nil
}
func takeoverChecklistSectionLabel(section string) string {
switch section {
case helicopterfile.SectionBeforeFirstFlightInspection:
return "inspection.before.file_checklist"
case helicopterfile.SectionFlightPreparationAndWB:
return "inspection.prepare.file_checklist"
default:
return fmt.Sprintf("inspection.%s.file_checklist", section)
}
}
// resolveOtherPeopleMasterInputTx fills each id-referenced other-person's name/phone/email
// from the master (a snapshot), so an id-only reuse still passes the "name required"
// validation and later upserts to the same master.
func resolveOtherPeopleMasterInputTx(tx *gorm.DB, detail *requestdto.DutyRosterDetailsInput) error {
if detail == nil {
return nil
}
for i := range detail.OtherPerson {
idStr := strings.TrimSpace(detail.OtherPerson[i].OtherPersonID)
if idStr == "" {
continue
}
id, err := uuidv7.ParseString(idStr)
if err != nil {
return errors.New("other_person_id is invalid UUID")
}
var master otherpersondomain.OtherPerson
if err := tx.Where("id = ? AND deleted_at IS NULL", id).First(&master).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("other_person_id not found")
}
return err
}
detail.OtherPerson[i].Name = master.Name
detail.OtherPerson[i].MobilePhone = master.MobilePhone
detail.OtherPerson[i].Email = master.Email
}
return nil
}
// upsertOtherPersonTx returns the canonical master person for (name, mobile_phone, email),
// creating it when absent. On return p.ID is the canonical id.
func upsertOtherPersonTx(tx *gorm.DB, p *otherpersondomain.OtherPerson) error {
name := strings.TrimSpace(p.Name)
phone := strings.TrimSpace(p.MobilePhone)
email := strings.TrimSpace(p.Email)
if name == "" {
return nil
}
var existing otherpersondomain.OtherPerson
err := tx.Where("name = ? AND mobile_phone = ? AND email = ? AND deleted_at IS NULL", name, phone, email).First(&existing).Error
if err == nil {
p.ID = existing.ID
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
p.Name, p.MobilePhone, p.Email = name, phone, email
return tx.Create(p).Error
}
func buildTakeoverCrewRows(details *requestdto.DutyRosterDetailsInput, actor []byte) ([]takeoverdomain.TakeoverRosterCrew, []takeoverdomain.TakeoverOtherPerson, *DutyRosterValidationError) {
if details == nil {
return []takeoverdomain.TakeoverRosterCrew{}, []takeoverdomain.TakeoverOtherPerson{}, nil
}
rows, err := BuildDutyRosterCrewRows(*details, actor)
if err != nil {
return nil, nil, err
}
crews := make([]takeoverdomain.TakeoverRosterCrew, 0, len(rows))
others := make([]takeoverdomain.TakeoverOtherPerson, 0)
for i := range rows {
if strings.EqualFold(strings.TrimSpace(rows[i].RoleCode), "other_person") {
others = append(others, takeoverdomain.TakeoverOtherPerson{
Name: rows[i].NameLabel,
MobilePhone: rows[i].MobilePhone,
Email: rows[i].Email,
CreatedBy: actor,
UpdatedBy: actor,
})
continue
}
crews = append(crews, takeoverdomain.TakeoverRosterCrew{
RoleCode: rows[i].RoleCode,
CrewType: rows[i].CrewType,
UserID: append([]byte(nil), rows[i].UserID...),
NameLabel: rows[i].NameLabel,
MobilePhone: rows[i].MobilePhone,
Email: rows[i].Email,
DateStart: rows[i].DateStart,
DateEnd: rows[i].DateEnd,
ShiftStart: nullableTimeStringPtr(rows[i].ShiftStart),
ShiftEnd: nullableTimeStringPtr(rows[i].ShiftEnd),
FlightInstructor: rows[i].FlightInstructor,
LineChecker: rows[i].LineChecker,
Supervisor: rows[i].Supervisor,
Examiner: rows[i].Examiner,
CoPilot: rows[i].CoPilot,
CreatedBy: actor,
UpdatedBy: actor,
})
}
return crews, others, nil
}
func takeoverRosterCrewIdentityKey(row takeoverdomain.TakeoverRosterCrew) string {
return fmt.Sprintf(
"%s|%s|%s|%s|%s|%s|%s|%s|%s",
strings.ToLower(strings.TrimSpace(row.RoleCode)),
bytesKey(row.UserID),
strings.ToLower(strings.TrimSpace(row.CrewType)),
strings.ToLower(strings.TrimSpace(row.NameLabel)),
strings.ToLower(strings.TrimSpace(row.MobilePhone)),
strings.ToLower(strings.TrimSpace(row.Email)),
nullableDateKey(row.DateStart),
nullableDateKey(row.DateEnd),
nullableTimeStringKey(row.ShiftStart)+"|"+nullableTimeStringKey(row.ShiftEnd),
)
}
func takeoverRosterCrewPayloadEqual(a, b takeoverdomain.TakeoverRosterCrew) bool {
return bytes.Equal(a.UserID, b.UserID) &&
strings.EqualFold(strings.TrimSpace(a.RoleCode), strings.TrimSpace(b.RoleCode)) &&
strings.EqualFold(strings.TrimSpace(a.CrewType), strings.TrimSpace(b.CrewType)) &&
strings.TrimSpace(a.NameLabel) == strings.TrimSpace(b.NameLabel) &&
strings.TrimSpace(a.MobilePhone) == strings.TrimSpace(b.MobilePhone) &&
strings.TrimSpace(a.Email) == strings.TrimSpace(b.Email) &&
nullableDateEqual(a.DateStart, b.DateStart) &&
nullableDateEqual(a.DateEnd, b.DateEnd) &&
nullableTimeStringEqual(a.ShiftStart, b.ShiftStart) &&
nullableTimeStringEqual(a.ShiftEnd, b.ShiftEnd) &&
a.FlightInstructor == b.FlightInstructor &&
a.LineChecker == b.LineChecker &&
a.Supervisor == b.Supervisor &&
a.Examiner == b.Examiner &&
a.CoPilot == b.CoPilot
}
func nullableDateEqual(a, b *time.Time) bool {
switch {
case a == nil && b == nil:
return true
case a == nil || b == nil:
return false
default:
return dateOnlyUTC(*a).Equal(dateOnlyUTC(*b))
}
}
func nullableDateKey(v *time.Time) string {
if v == nil {
return "nil"
}
return dateOnlyUTC(*v).Format("2006-01-02")
}
func bytesKey(v []byte) string {
if len(v) == 0 {
return "nil"
}
return fmt.Sprintf("%x", v)
}
func nullableTimeStringPtr(v string) *string {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return nil
}
return &trimmed
}
func nullableTimeStringKey(v *string) string {
if v == nil {
return "nil"
}
return strings.TrimSpace(*v)
}
func nullableTimeStringEqual(a, b *string) bool {
switch {
case a == nil && b == nil:
return true
case a == nil || b == nil:
return false
default:
return strings.TrimSpace(*a) == strings.TrimSpace(*b)
}
}
func loadTakeoverRosterCrewsTx(tx *gorm.DB, takeoverID []byte) ([]takeoverdomain.TakeoverRosterCrew, error) {
rows := make([]takeoverdomain.TakeoverRosterCrew, 0)
if err := tx.Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTakeoverOtherPeopleTx(tx *gorm.DB, takeoverID []byte) ([]takeoverdomain.TakeoverOtherPerson, error) {
rows := make([]takeoverdomain.TakeoverOtherPerson, 0)
if err := tx.Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (s *TakeoverService) upsertTakeoverRosterRowsTx(
tx *gorm.DB,
takeoverID []byte,
in TakeoverCreateInput,
) error {
// Reuse: fill id-referenced other-people from the master (snapshot) BEFORE rows are
// built, so an id-only reuse still passes the "name required" validation.
if err := resolveOtherPeopleMasterInputTx(tx, in.RosterDetail); err != nil {
return err
}
crews, others, crewErr := buildTakeoverCrewRows(in.RosterDetail, in.ActorUserID)
if crewErr != nil {
if strings.TrimSpace(crewErr.Detail) != "" {
return errors.New(crewErr.Detail)
}
return errors.New("invalid roster_detail")
}
if len(crews) == 0 && len(others) == 0 {
return nil
}
// Register/link each other-person to the reusable master (other_people). The takeover
// keeps its own snapshot (name/phone/email); other_person_id points at the master.
for i := range others {
master := &otherpersondomain.OtherPerson{
Name: others[i].Name,
MobilePhone: others[i].MobilePhone,
Email: others[i].Email,
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if err := upsertOtherPersonTx(tx, master); err != nil {
return err
}
others[i].OtherPersonID = append([]byte(nil), master.ID...)
}
existingCrews, err := loadTakeoverRosterCrewsTx(tx, takeoverID)
if err != nil {
return err
}
existingOthers, err := loadTakeoverOtherPeopleTx(tx, takeoverID)
if err != nil {
return err
}
existingCrewMap := make(map[string]takeoverdomain.TakeoverRosterCrew, len(existingCrews))
for i := range existingCrews {
existingCrewMap[takeoverRosterCrewIdentityKey(existingCrews[i])] = existingCrews[i]
}
existingOtherMap := make(map[string]takeoverdomain.TakeoverOtherPerson, len(existingOthers))
for i := range existingOthers {
key := strings.ToLower(strings.TrimSpace(existingOthers[i].Name)) + "|" +
strings.ToLower(strings.TrimSpace(existingOthers[i].MobilePhone)) + "|" +
strings.ToLower(strings.TrimSpace(existingOthers[i].Email))
existingOtherMap[key] = existingOthers[i]
}
touchedCrewKeys := make(map[string]struct{}, len(crews))
touchedOtherKeys := make(map[string]struct{}, len(others))
for i := range crews {
crews[i].TakeoverID = append([]byte(nil), takeoverID...)
key := takeoverRosterCrewIdentityKey(crews[i])
touchedCrewKeys[key] = struct{}{}
if existingRow, ok := existingCrewMap[key]; ok {
crews[i].ID = append([]byte(nil), existingRow.ID...)
if takeoverRosterCrewPayloadEqual(existingRow, crews[i]) {
continue
}
if err := tx.Model(&takeoverdomain.TakeoverRosterCrew{}).
Where("id = ? AND deleted_at IS NULL", crews[i].ID).
Updates(map[string]any{
"takeover_id": crews[i].TakeoverID,
"confirm_at": crews[i].ConfirmAt,
"role_code": crews[i].RoleCode,
"crew_type": crews[i].CrewType,
"user_id": crews[i].UserID,
"name_label": crews[i].NameLabel,
"mobile_phone": crews[i].MobilePhone,
"email": crews[i].Email,
"date_start": crews[i].DateStart,
"date_end": crews[i].DateEnd,
"shift_start": crews[i].ShiftStart,
"shift_end": crews[i].ShiftEnd,
"flight_instructor": crews[i].FlightInstructor,
"line_checker": crews[i].LineChecker,
"supervisor": crews[i].Supervisor,
"examiner": crews[i].Examiner,
"co_pilot": crews[i].CoPilot,
"updated_by": in.ActorUserID,
}).Error; err != nil {
return err
}
continue
}
if err := tx.Create(&crews[i]).Error; err != nil {
return err
}
}
for _, existingRow := range existingCrews {
key := takeoverRosterCrewIdentityKey(existingRow)
if _, ok := touchedCrewKeys[key]; ok {
continue
}
if err := tx.Model(&takeoverdomain.TakeoverRosterCrew{}).
Where("id = ? AND deleted_at IS NULL", existingRow.ID).
Updates(map[string]any{"deleted_at": time.Now().UTC(), "deleted_by": in.ActorUserID, "updated_by": in.ActorUserID}).Error; err != nil {
return err
}
}
for i := range others {
others[i].TakeoverID = append([]byte(nil), takeoverID...)
key := strings.ToLower(strings.TrimSpace(others[i].Name)) + "|" +
strings.ToLower(strings.TrimSpace(others[i].MobilePhone)) + "|" +
strings.ToLower(strings.TrimSpace(others[i].Email))
touchedOtherKeys[key] = struct{}{}
if existingRow, ok := existingOtherMap[key]; ok {
others[i].ID = append([]byte(nil), existingRow.ID...)
if strings.TrimSpace(existingRow.Name) == strings.TrimSpace(others[i].Name) &&
strings.TrimSpace(existingRow.MobilePhone) == strings.TrimSpace(others[i].MobilePhone) &&
strings.TrimSpace(existingRow.Email) == strings.TrimSpace(others[i].Email) {
continue
}
if err := tx.Model(&takeoverdomain.TakeoverOtherPerson{}).
Where("id = ? AND deleted_at IS NULL", others[i].ID).
Updates(map[string]any{
"takeover_id": others[i].TakeoverID,
"other_person_id": others[i].OtherPersonID,
"name": others[i].Name,
"mobile_phone": others[i].MobilePhone,
"email": others[i].Email,
"updated_by": in.ActorUserID,
}).Error; err != nil {
return err
}
continue
}
if err := tx.Create(&others[i]).Error; err != nil {
return err
}
}
for _, existingRow := range existingOthers {
key := strings.ToLower(strings.TrimSpace(existingRow.Name)) + "|" +
strings.ToLower(strings.TrimSpace(existingRow.MobilePhone)) + "|" +
strings.ToLower(strings.TrimSpace(existingRow.Email))
if _, ok := touchedOtherKeys[key]; ok {
continue
}
if err := tx.Model(&takeoverdomain.TakeoverOtherPerson{}).
Where("id = ? AND deleted_at IS NULL", existingRow.ID).
Updates(map[string]any{"deleted_at": time.Now().UTC(), "deleted_by": in.ActorUserID, "updated_by": in.ActorUserID}).Error; err != nil {
return err
}
}
return nil
}
func (s *TakeoverService) upsertTakeoverInspectionTx(
tx *gorm.DB,
takeoverID []byte,
inspectionID []byte,
in *requestdto.ReserveAcInspectionCreateAttributes,
helicopterID []byte,
actor []byte,
) ([]byte, error) {
if in == nil {
return inspectionID, nil
}
if len(helicopterID) != 16 {
return nil, errors.New("helicopter_id is required for inspection data")
}
// Every file edited/created from an FPWB template for this takeover must be
// represented in the submitted flight-preparation file checklist. Files that
// were not edited need not be in the checklist.
if err := s.validateTakeoverEditedTemplateFilesTx(tx, takeoverID, helicopterID, in.Prepare.FileChecklist); err != nil {
return nil, err
}
inspectionDate, err := time.Parse("2006-01-02", strings.TrimSpace(in.InspectionDate))
if err != nil {
return nil, errors.New("inspection.inspection_date must be YYYY-MM-DD")
}
if len(inspectionID) == 0 {
inspection := &flightinspection.FlightInspection{
InspectionDate: dateOnlyUTC(inspectionDate),
Status: flightinspection.StatusDraft,
CreatedBy: actor,
UpdatedBy: actor,
}
if err := tx.Create(inspection).Error; err != nil {
return nil, err
}
inspectionID = append([]byte(nil), inspection.ID...)
} else {
if err := tx.Model(&flightinspection.FlightInspection{}).
Where("id = ?", inspectionID).
Updates(map[string]any{
"inspection_date": inspectionDate,
"updated_by": actor,
}).Error; err != nil {
return nil, err
}
}
beforeRow := &beforeflightinspection.BeforeFlightInspection{
FlightInspectionID: append([]byte(nil), inspectionID...),
OilEngineNR1Checked: in.Before.OilEngineNR1Checked,
OilEngineNR2Checked: in.Before.OilEngineNR2Checked,
HydraulicLHChecked: in.Before.HydraulicLHChecked,
HydraulicRHChecked: in.Before.HydraulicRHChecked,
OilTransmissionMGBChecked: in.Before.OilTransmissionMGBChecked,
OilTransmissionIGBChecked: in.Before.OilTransmissionIGBChecked,
OilTransmissionTGBChecked: in.Before.OilTransmissionTGBChecked,
FuelAmount: in.Before.FuelAmount,
FuelAddedAmount: in.Before.FuelAddedAmount,
Note: in.Before.Note,
CreatedBy: actor,
UpdatedBy: actor,
}
if in.Before.FuelUnit != nil {
unit, ok := beforeflightinspection.NormalizeFuelUnit(*in.Before.FuelUnit)
if !ok {
return nil, errors.New("inspection.before.fuel_unit must be one of LT, KG, POUND")
}
beforeRow.FuelUnit = unit
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "flight_inspection_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"oil_engine_nr1_checked",
"oil_engine_nr2_checked",
"hydraulic_lh_checked",
"hydraulic_rh_checked",
"oil_transmission_mgb_checked",
"oil_transmission_igb_checked",
"oil_transmission_tgb_checked",
"fuel_amount",
"fuel_unit",
"fuel_added_amount",
"note",
"updated_by",
}),
}).Create(beforeRow).Error; err != nil {
return nil, err
}
prepRow := &flightprepcheck.FlightPrepCheck{
FlightInspectionID: append([]byte(nil), inspectionID...),
CreatedBy: actor,
UpdatedBy: actor,
}
if in.Prepare.NotamBriefing != nil {
prepRow.NOTAMBriefing = *in.Prepare.NotamBriefing
}
if in.Prepare.WeatherBriefing != nil {
prepRow.WeatherBriefing = *in.Prepare.WeatherBriefing
}
if in.Prepare.OperationalFlightPlan != nil {
prepRow.OperationalFlightPlan = *in.Prepare.OperationalFlightPlan
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "flight_inspection_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"notam_briefing",
"weather_briefing",
"operational_flight_plan",
"updated_by",
}),
}).Create(prepRow).Error; err != nil {
return nil, err
}
if len(in.Before.FileChecklist) > 0 {
if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionBeforeFirstFlightInspection, in.Before.FileChecklist, actor); err != nil {
return nil, err
}
}
if len(in.Prepare.FileChecklist) > 0 {
if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionFlightPreparationAndWB, in.Prepare.FileChecklist, actor); err != nil {
return nil, err
}
}
return inspectionID, nil
}
func (s *TakeoverService) upsertTakeoverReserveTx(
tx *gorm.DB,
takeoverID []byte,
baseID []byte,
existing *takeoverExisting,
in TakeoverCreateInput,
) error {
if existing == nil {
return errors.New("takeover state is not loaded")
}
if existing.Reserve == nil {
if !in.HasHelicopterID && !in.HasInspection {
return nil
}
if !in.HasHelicopterID || !in.HasInspection || in.Inspection == nil {
return errors.New("helicopter_id and inspection are required to create reserve data")
}
if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
logTakeoverServiceStepError("upsertTakeoverReserveTx.ensureHelicopterExistsTx", err)
return err
}
inspectionID, err := s.upsertTakeoverInspectionTx(tx, takeoverID, nil, in.Inspection, in.HelicopterID, in.ActorUserID)
if err != nil {
logTakeoverServiceStepError("upsertTakeoverReserveTx.upsertTakeoverInspectionTx(create)", err)
return err
}
reserveRow := &reserveac.ReserveAc{
Status: reserveacutil.StatusPending,
BaseID: append([]byte(nil), baseID...),
Notes: in.Notes,
AircraftID: append([]byte(nil), in.HelicopterID...),
InspectionID: inspectionID,
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if err := tx.Create(reserveRow).Error; err != nil {
logTakeoverServiceStepError("upsertTakeoverReserveTx.createReserve", err)
return err
}
if err := tx.Model(&takeoverdomain.TakeoverAc{}).
Where("id = ? AND deleted_at IS NULL", takeoverID).
Updates(map[string]any{"reserve_ac_id": reserveRow.ID, "updated_by": in.ActorUserID}).Error; err != nil {
logTakeoverServiceStepError("upsertTakeoverReserveTx.attachReserveToTakeover", err)
return err
}
existing.Reserve = reserveRow
return nil
}
changed := false
if len(baseID) == 16 && !bytes.Equal(existing.Reserve.BaseID, baseID) {
existing.Reserve.BaseID = append([]byte(nil), baseID...)
changed = true
}
if in.Notes != nil && (existing.Reserve.Notes == nil || *existing.Reserve.Notes != *in.Notes) {
existing.Reserve.Notes = in.Notes
changed = true
}
if in.HasHelicopterID {
if len(in.HelicopterID) != 16 {
return errors.New("helicopter_id must be a valid UUID")
}
if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
logTakeoverServiceStepError("upsertTakeoverReserveTx.ensureHelicopterExistsTx(update)", err)
return err
}
if !bytes.Equal(existing.Reserve.AircraftID, in.HelicopterID) {
existing.Reserve.AircraftID = append([]byte(nil), in.HelicopterID...)
changed = true
}
}
if in.HasInspection {
if in.Inspection == nil {
return errors.New("inspection data is required")
}
helicopterID := existing.Reserve.AircraftID
if in.HasHelicopterID {
helicopterID = in.HelicopterID
}
if len(helicopterID) != 16 {
return errors.New("helicopter_id is required for inspection data")
}
inspectionID, err := s.upsertTakeoverInspectionTx(tx, takeoverID, existing.Reserve.InspectionID, in.Inspection, helicopterID, in.ActorUserID)
if err != nil {
logTakeoverServiceStepError("upsertTakeoverReserveTx.upsertTakeoverInspectionTx(update)", err)
return err
}
if len(existing.Reserve.InspectionID) != 16 || !bytes.Equal(existing.Reserve.InspectionID, inspectionID) {
existing.Reserve.InspectionID = append([]byte(nil), inspectionID...)
changed = true
}
}
if changed {
existing.Reserve.UpdatedBy = in.ActorUserID
if err := tx.Save(existing.Reserve).Error; err != nil {
logTakeoverServiceStepError("upsertTakeoverReserveTx.saveReserve", err)
return err
}
}
return nil
}
/*
func (s *TakeoverService) upsertTakeoverReserveTx(
tx *gorm.DB,
takeoverID []byte,
baseID []byte,
existing *takeoverExisting,
in TakeoverCreateInput,
) error {
if existing == nil {
return errors.New("takeover state is not available")
}
if existing.Reserve == nil {
if !in.HasHelicopterID && !in.HasInspection {
return nil
}
if !in.HasHelicopterID || len(in.HelicopterID) != 16 {
return errors.New("helicopter_id is required to create reserve data")
}
if !in.HasInspection || in.Inspection == nil {
return errors.New("inspection is required to create reserve data")
}
if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
return err
}
inspectionID, err := upsertTakeoverInspectionTx(tx, nil, in.Inspection, in.ActorUserID, in.HelicopterID)
if err != nil {
return err
}
reserveRow := &reserveac.ReserveAc{
Status: reserveacutil.StatusPending,
BaseID: append([]byte(nil), baseID...),
Notes: in.Notes,
AircraftID: append([]byte(nil), in.HelicopterID...),
InspectionID: append([]byte(nil), inspectionID...),
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if err := tx.Create(reserveRow).Error; err != nil {
return err
}
if err := tx.Model(&takeoverdomain.TakeoverAc{}).
Where("id = ? AND deleted_at IS NULL", takeoverID).
Updates(map[string]any{"reserve_ac_id": reserveRow.ID, "updated_by": in.ActorUserID}).Error; err != nil {
return err
}
existing.Reserve = reserveRow
return nil
}
changed := false
if in.HasBaseID && !bytes.Equal(existing.Reserve.BaseID, baseID) {
existing.Reserve.BaseID = append([]byte(nil), baseID...)
changed = true
}
if in.Notes != nil && (existing.Reserve.Notes == nil || strings.TrimSpace(*existing.Reserve.Notes) != strings.TrimSpace(*in.Notes)) {
existing.Reserve.Notes = in.Notes
changed = true
}
if in.HasHelicopterID {
if len(in.HelicopterID) != 16 {
return errors.New("helicopter_id must be a valid UUID")
}
if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
return err
}
if !bytes.Equal(existing.Reserve.AircraftID, in.HelicopterID) {
existing.Reserve.AircraftID = append([]byte(nil), in.HelicopterID...)
changed = true
}
}
if in.HasInspection {
if in.Inspection == nil {
return errors.New("inspection data is required when inspection is provided")
}
heliID := existing.Reserve.AircraftID
if in.HasHelicopterID {
heliID = in.HelicopterID
}
if len(heliID) != 16 {
return errors.New("helicopter_id is required to update inspection data")
}
inspectionID, err := upsertTakeoverInspectionTx(tx, existing.Reserve.InspectionID, in.Inspection, in.ActorUserID, heliID)
if err != nil {
return err
}
if len(existing.Reserve.InspectionID) != 16 || !bytes.Equal(existing.Reserve.InspectionID, inspectionID) {
existing.Reserve.InspectionID = append([]byte(nil), inspectionID...)
changed = true
}
}
if changed {
existing.Reserve.UpdatedBy = in.ActorUserID
if err := tx.Save(existing.Reserve).Error; err != nil {
return err
}
}
return nil
}
func upsertTakeoverInspectionTx(tx *gorm.DB, inspectionID []byte, in *requestdto.ReserveAcInspectionCreateAttributes, actor []byte, helicopterID []byte) ([]byte, error) {
if in == nil {
return inspectionID, nil
}
existingInspection := len(inspectionID) == 16
if len(helicopterID) != 16 {
return nil, errors.New("helicopter_id is required to update inspection data")
}
if err := validateTakeoverInspectionChecklists(in); err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.validateTakeoverInspectionChecklists", err)
return nil, err
}
inspectionDate, err := time.Parse("2006-01-02", strings.TrimSpace(in.InspectionDate))
if err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.parseInspectionDate", err)
return nil, errors.New("inspection.inspection_date must be YYYY-MM-DD")
}
if len(inspectionID) != 16 {
inspection := &flightinspection.FlightInspection{
InspectionDate: dateOnlyUTC(inspectionDate),
Status: flightinspection.StatusDraft,
CreatedBy: actor,
UpdatedBy: actor,
}
if err := tx.Create(inspection).Error; err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.createFlightInspection", err)
return nil, err
}
inspectionID = append([]byte(nil), inspection.ID...)
} else if err := tx.Model(&flightinspection.FlightInspection{}).
Where("id = ?", inspectionID).
Updates(map[string]any{"inspection_date": dateOnlyUTC(inspectionDate), "updated_by": actor}).Error; err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.updateFlightInspection", err)
return nil, err
}
beforeRow := &beforeflightinspection.BeforeFlightInspection{
FlightInspectionID: append([]byte(nil), inspectionID...),
OilEngineNR1Checked: in.Before.OilEngineNR1Checked,
OilEngineNR2Checked: in.Before.OilEngineNR2Checked,
HydraulicLHChecked: in.Before.HydraulicLHChecked,
HydraulicRHChecked: in.Before.HydraulicRHChecked,
OilTransmissionMGBChecked: in.Before.OilTransmissionMGBChecked,
OilTransmissionIGBChecked: in.Before.OilTransmissionIGBChecked,
OilTransmissionTGBChecked: in.Before.OilTransmissionTGBChecked,
FuelAmount: in.Before.FuelAmount,
FuelAddedAmount: in.Before.FuelAddedAmount,
Note: in.Before.Note,
CreatedBy: actor,
UpdatedBy: actor,
}
if in.Before.FuelUnit != nil {
unit, ok := beforeflightinspection.NormalizeFuelUnit(*in.Before.FuelUnit)
if !ok {
return nil, errors.New("inspection.before.fuel_unit must be one of LT, KG, POUND")
}
beforeRow.FuelUnit = unit
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "flight_inspection_id"}},
DoUpdates: clause.AssignmentColumns([]string{"oil_engine_nr1_checked", "oil_engine_nr2_checked", "hydraulic_lh_checked", "hydraulic_rh_checked", "oil_transmission_mgb_checked", "oil_transmission_igb_checked", "oil_transmission_tgb_checked", "fuel_amount", "fuel_unit", "fuel_added_amount", "note", "updated_by"}),
}).Create(beforeRow).Error; err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.upsertBeforeFlightInspection", err)
return nil, err
}
prepRow := &flightprepcheck.FlightPrepCheck{
FlightInspectionID: append([]byte(nil), inspectionID...),
CreatedBy: actor,
UpdatedBy: actor,
}
if in.Prepare.NotamBriefing != nil {
prepRow.NOTAMBriefing = *in.Prepare.NotamBriefing
}
if in.Prepare.WeatherBriefing != nil {
prepRow.WeatherBriefing = *in.Prepare.WeatherBriefing
}
if in.Prepare.OperationalFlightPlan != nil {
prepRow.OperationalFlightPlan = *in.Prepare.OperationalFlightPlan
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "flight_inspection_id"}},
DoUpdates: clause.AssignmentColumns([]string{"notam_briefing", "weather_briefing", "operational_flight_plan", "updated_by"}),
}).Create(prepRow).Error; err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.upsertFlightPrepCheck", err)
return nil, err
}
if !existingInspection || len(in.Before.FileChecklist) > 0 {
if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionBeforeFirstFlightInspection, in.Before.FileChecklist, actor); err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.beforeChecklist", err)
return nil, err
}
}
if !existingInspection || len(in.Prepare.FileChecklist) > 0 {
if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionFlightPreparationAndWB, in.Prepare.FileChecklist, actor); err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.prepareChecklist", err)
return nil, err
}
}
// On updates, an omitted fleet_status_file block means "keep the existing
// checklist as-is". Only enforce the mandatory-file check when the caller
// actually sends the section, or when we're creating a brand new inspection.
if !existingInspection || len(in.FleetStatusFile.FileChecklist) > 0 {
if err := validateMandatoryFleetStatusFilesTx(tx, helicopterID, in.FleetStatusFile.FileChecklist); err != nil {
logTakeoverServiceStepError("upsertTakeoverInspectionTx.validateMandatoryFleetStatusFilesTx", err)
return nil, err
}
}
return inspectionID, nil
}
*/
func validateTakeoverInspectionChecklists(in *requestdto.ReserveAcInspectionCreateAttributes) error {
if in == nil {
return nil
}
if err := validateTakeoverChecklistSection(in.Before.FileChecklist, "inspection.before.file_checklist"); err != nil {
return err
}
if err := validateTakeoverChecklistSection(in.Prepare.FileChecklist, "inspection.prepare.file_checklist"); err != nil {
return err
}
return nil
}
func validateTakeoverChecklistSection(items []requestdto.ReserveAcFileChecklistItem, label string) error {
if len(items) == 0 {
return nil
}
for i := range items {
if !items[i].IsDone {
return fmt.Errorf("%s must be fully checked", label)
}
}
return nil
}
func validateMandatoryFleetStatusFilesTx(tx *gorm.DB, helicopterID []byte, items []requestdto.ReserveAcFileChecklistItem) error {
if len(helicopterID) != 16 {
return nil
}
type fsfRow struct {
ID []byte `gorm:"column:id"`
}
latest := tx.Table("fleet_statuses").
Select("id").
Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID).
Order("created_at DESC").
Limit(1)
mandatory := make([]fsfRow, 0)
if err := tx.Table("fleet_status_files").
Select("id").
Where("is_mandatory = ? AND fleet_status_id = (?)", true, latest).
Scan(&mandatory).Error; err != nil {
return err
}
if len(mandatory) == 0 {
return nil
}
done := make(map[string]bool, len(items))
for i := range items {
id, err := uuidv7.ParseString(strings.TrimSpace(items[i].HelicopterFileID))
if err != nil {
continue
}
done[string(id)] = items[i].IsDone
}
for _, m := range mandatory {
if !done[string(m.ID)] {
return fmt.Errorf("inspection.fleet_status_file: a mandatory file must be checked (is_done) before takeover")
}
}
return nil
}
func loadTakeoverReserveTx(tx *gorm.DB, id []byte) (*reserveac.ReserveAc, error) {
if len(id) != 16 {
return nil, nil
}
var row reserveac.ReserveAc
if err := tx.Where("id = ? AND deleted_at IS NULL", id).Take(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &row, nil
}
func getLatestMissionCodeByPrefixTx(tx *gorm.DB, prefix string) (string, error) {
var out struct {
MissionCode string `gorm:"column:mission_code"`
}
err := tx.Table("flights").
Select("mission_code").
Where("mission_code LIKE ? AND deleted_at IS NULL", prefix+"%").
Order("mission_code DESC").
Limit(1).
Scan(&out).Error
if err != nil {
return "", err
}
return out.MissionCode, nil
}