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 }