package service import ( "context" "errors" "fmt" "strings" "time" "wucher/internal/domain/flight" fmreport "wucher/internal/domain/fm_report" "wucher/internal/shared/pkg/appctx" "wucher/internal/shared/pkg/uuidv7" ) const reportCodeMaxAttempts = 5 type FMReportService struct { repo fmreport.Repository historyBuilder fmreport.FleetHistoryBuilder codeResolver fmreport.ReportCodeResolver usage interface { RefreshAll(context.Context) error } } func NewFMReportService(repo fmreport.Repository) *FMReportService { return &FMReportService{repo: repo} } func (s *FMReportService) WithFleetHistoryBuilder(builder fmreport.FleetHistoryBuilder) *FMReportService { s.historyBuilder = builder return s } func (s *FMReportService) WithReportCodeResolver(resolver fmreport.ReportCodeResolver) *FMReportService { s.codeResolver = resolver return s } func (s *FMReportService) WithUsageRefresher(refresher interface { RefreshAll(context.Context) error }) *FMReportService { s.usage = refresher return s } func (s *FMReportService) resolveHelicopterForReportCode(ctx context.Context, takeoverID []byte) ([]byte, string) { if s.codeResolver == nil || len(takeoverID) != 16 { return nil, "" } helicopterID, reportPrefix := s.codeResolver.ResolveHelicopterForReportCode(ctx, takeoverID) reportPrefix = strings.ToUpper(strings.TrimSpace(reportPrefix)) if len(helicopterID) != 16 || reportPrefix == "" { return nil, "" } return helicopterID, reportPrefix } func (s *FMReportService) assignReportCode(ctx context.Context, row *fmreport.Report, helicopterID []byte, reportPrefix string) { if len(helicopterID) != 16 || reportPrefix == "" || len(row.ID) != 16 { return } for attempt := 0; attempt < reportCodeMaxAttempts; attempt++ { maxSeq, err := s.repo.MaxReportSeqByHelicopter(ctx, helicopterID) if err != nil { return } code := reportPrefix + "-" + fmt.Sprintf("%04d", maxSeq+1) if err := s.repo.SetReportCode(ctx, row.ID, code); err != nil { if errors.Is(err, fmreport.ErrDuplicateReportCode) { continue } return } row.ReportCode = &code return } } func (s *FMReportService) GetFleetHistory(ctx context.Context, reportID []byte) (*fmreport.FleetHistory, error) { if len(reportID) != 16 { return nil, fmt.Errorf("report id must be a valid UUID") } return s.repo.GetFleetHistoryByReportID(ctx, reportID) } func (s *FMReportService) UpsertForFlight(ctx context.Context, flightRow *flight.Flight, flightInspectionID []byte) (*fmreport.Report, error) { if flightRow == nil || len(flightRow.ID) != 16 { return nil, fmt.Errorf("flight is required") } if len(flightRow.TakeoverAcID) != 16 { return nil, fmt.Errorf("takeover id is required") } if len(flightInspectionID) != 16 { return nil, fmt.Errorf("flight inspection id is required") } row := &fmreport.Report{ FlightID: flightRow.ID, TakeoverID: flightRow.TakeoverAcID, FlightInspectionID: flightInspectionID, } if userID := appctx.GetUserID(ctx); len(userID) == 16 { row.CreatedBy = userID row.UpdatedBy = userID } isNew := true if existing, err := s.repo.GetByFlightID(ctx, flightRow.ID); err == nil && existing != nil && len(existing.ID) == 16 { isNew = false if existing.CompletedAt != nil { return existing, nil } row.ID = existing.ID row.CreatedAt = existing.CreatedAt row.CreatedBy = existing.CreatedBy row.HelicopterID = existing.HelicopterID row.ReportCode = existing.ReportCode row.Engine1GpcN1 = existing.Engine1GpcN1 row.Engine1PtcN2 = existing.Engine1PtcN2 row.Engine2GpcN1 = existing.Engine2GpcN1 row.Engine2PtcN2 = existing.Engine2PtcN2 } var helicopterID []byte var reportPrefix string if isNew { helicopterID, reportPrefix = s.resolveHelicopterForReportCode(ctx, flightRow.TakeoverAcID) if len(helicopterID) == 16 { row.HelicopterID = helicopterID } } if err := s.repo.Upsert(ctx, row); err != nil { return nil, fmt.Errorf("failed to upsert fm report: %w", err) } if isNew { s.assignReportCode(ctx, row, helicopterID, reportPrefix) } // The fleet-status snapshot is frozen at COMPLETION (see Complete), not here — while the // report is open its fleet_status is shown live so fleet updates reflect. return row, nil } // snapshotFleetStatus freezes the current fleet status for a report (idempotent: no-op if a // snapshot already exists for the report). func (s *FMReportService) snapshotFleetStatus(ctx context.Context, row *fmreport.Report) { if s.historyBuilder == nil || len(row.ID) != 16 { return } if existing, _ := s.repo.GetFleetHistoryByReportID(ctx, row.ID); existing != nil { return } if history := s.historyBuilder.BuildFleetHistory(ctx, row.TakeoverID); history != nil { history.FMReportID = row.ID _ = s.repo.CreateFleetHistory(ctx, history) } } func (s *FMReportService) UpdateForFlight(ctx context.Context, flightID []byte, engine1GpcN1, engine1PtcN2, engine2GpcN1, engine2PtcN2 *string) (*fmreport.Report, error) { if len(flightID) != 16 { return nil, fmt.Errorf("flight id must be a valid UUID") } row, err := s.repo.GetByFlightID(ctx, flightID) if err != nil { return nil, err } if row == nil || len(row.ID) != 16 { return nil, fmreport.ErrNotFound } if row.CompletedAt != nil { return nil, fmreport.ErrCompleted } if engine1GpcN1 != nil { v := strings.TrimSpace(*engine1GpcN1) row.Engine1GpcN1 = &v } if engine1PtcN2 != nil { v := strings.TrimSpace(*engine1PtcN2) row.Engine1PtcN2 = &v } if engine2GpcN1 != nil { v := strings.TrimSpace(*engine2GpcN1) row.Engine2GpcN1 = &v } if engine2PtcN2 != nil { v := strings.TrimSpace(*engine2PtcN2) row.Engine2PtcN2 = &v } if userID := appctx.GetUserID(ctx); len(userID) == 16 { row.UpdatedBy = userID } if err := s.repo.Upsert(ctx, row); err != nil { return nil, fmt.Errorf("failed to update fm report: %w", err) } return row, nil } func (s *FMReportService) Complete(ctx context.Context, flightID []byte) (*fmreport.Report, error) { if len(flightID) != 16 { return nil, fmt.Errorf("flight id must be a valid UUID") } row, err := s.repo.GetByFlightID(ctx, flightID) if err != nil { return nil, err } if row == nil || len(row.ID) != 16 { return nil, fmreport.ErrNotFound } if row.CompletedAt != nil { if s.usage != nil { if err := s.usage.RefreshAll(ctx); err != nil { return nil, err } } return row, nil } now := time.Now().UTC() row.CompletedAt = &now if userID := appctx.GetUserID(ctx); len(userID) == 16 { row.CompletedBy = userID row.UpdatedBy = userID } if err := s.repo.Upsert(ctx, row); err != nil { return nil, fmt.Errorf("failed to complete fm report: %w", err) } // Freeze the fleet-status snapshot at the moment of completion. s.snapshotFleetStatus(ctx, row) if s.usage != nil { if err := s.usage.RefreshAll(ctx); err != nil { return nil, err } } return row, nil } func (s *FMReportService) GetByID(ctx context.Context, id []byte) (*fmreport.Report, error) { if len(id) != 16 { return nil, fmt.Errorf("id must be a valid UUID") } return s.repo.GetByID(ctx, id) } func (s *FMReportService) GetByFlightID(ctx context.Context, flightID []byte) (*fmreport.Report, error) { if len(flightID) != 16 { return nil, fmt.Errorf("flight id must be a valid UUID") } row, err := s.repo.GetByFlightID(ctx, flightID) if err != nil { return nil, err } return row, nil } func (s *FMReportService) List(ctx context.Context, filter fmreport.ListFilter, limit, offset int) ([]fmreport.Report, int64, error) { return s.repo.List(ctx, filter, limit, offset) } func (s *FMReportService) Delete(ctx context.Context, id []byte) error { if len(id) != 16 { return fmt.Errorf("id must be a valid UUID") } row, err := s.repo.GetByID(ctx, id) if err != nil { return err } if row == nil || len(row.ID) != 16 { return fmreport.ErrNotFound } var deletedBy []byte if userID := appctx.GetUserID(ctx); len(userID) == 16 { deletedBy = userID } return s.repo.Delete(ctx, id, deletedBy) } func (s *FMReportService) AttachmentReferenceType() string { return "fm_report" } func (s *FMReportService) AttachmentReferenceID(row *fmreport.Report) string { if row == nil { return "" } id, err := uuidv7.BytesToString(row.ID) if err != nil { return "" } return strings.TrimSpace(id) }