package service import ( "context" "fmt" "log/slog" "strings" "time" complaint "wucher/internal/domain/complaint" filemanager "wucher/internal/domain/file_manager" fleethistory "wucher/internal/domain/fleet_history" fleetstatus "wucher/internal/domain/fleet_status" "wucher/internal/domain/helicopter" helicopterusage "wucher/internal/domain/helicopter_usage" "wucher/internal/shared/pkg/nextdue" "wucher/internal/shared/pkg/uuidv7" shareddto "wucher/internal/transport/http/dto/shared" ) type fleetStatusAttachments interface { CreateAttachment(ctx context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) DeleteAttachment(ctx context.Context, id []byte) error } type fleetStatusHelicopters interface { GetByID(ctx context.Context, id []byte) (*helicopter.Helicopter, error) Update(ctx context.Context, h *helicopter.Helicopter) error } type fleetStatusComplaints interface { ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]complaint.Complaint, int64, error) ResolveUserNames(ctx context.Context, complaints []complaint.Complaint) (map[string]string, error) } type fleetStatusUsage interface { GetByHelicopterID(ctx context.Context, helicopterID []byte) (*helicopterusage.HelicopterUsage, error) ReviseManualTotals(ctx context.Context, helicopterID []byte, in helicopterusage.ManualSummaryInput, actor []byte) error } const ( FleetStatusFileRefType = "fleet_status" autoAOGReasonPrefixNextDue = "[auto_next_due]" autoAOGReasonPrefixComplaint = "[auto_complaint]" ) type NextDueEvaluation struct { InspectionType string AOG bool Reason string } type ComponentHours struct { Airframe float64 Engine1 float64 Engine2 float64 } type FleetStatusService struct { repo fleetstatus.Repository attachments fleetStatusAttachments helicopters fleetStatusHelicopters complaints fleetStatusComplaints history *FleetHistoryService usage fleetStatusUsage } func (s *FleetStatusService) Create(ctx context.Context, row *fleetstatus.FleetStatus) error { if s.attachments == nil { if err := s.repo.Create(ctx, row); err != nil { return err } return s.syncAutomaticAOG(ctx, row) } if len(row.ID) == 0 { row.ID = uuidv7.MustBytes() } files, createdIDs, err := s.resolveFileLinks(ctx, row.ID, row.Files, nil, row.UpdatedBy) if err != nil { return err } row.Files = files if err := s.repo.Create(ctx, row); err != nil { s.rollbackAttachments(ctx, createdIDs) return err } if err := s.syncAutomaticAOG(ctx, row); err != nil { return err } return nil } func (s *FleetStatusService) Update(ctx context.Context, row *fleetstatus.FleetStatus) error { if s.attachments == nil { if err := s.repo.Update(ctx, row); err != nil { return err } return s.syncAutomaticAOG(ctx, row) } newKeys := make(map[string]struct{}) for i := range row.Files { if len(row.Files[i].FileID) == 16 { key, _ := uuidv7.BytesToString(row.Files[i].FileID) newKeys[key] = struct{}{} } } if len(newKeys) == 0 { if err := s.repo.Update(ctx, row); err != nil { return err } return s.syncAutomaticAOG(ctx, row) } existing, err := s.repo.GetByID(ctx, row.ID) if err != nil { return err } reuse := make(map[string][]byte) type oldRef struct { key string attID []byte } var olds []oldRef if existing != nil { for i := range existing.Files { ef := existing.Files[i] if ef.Attachment == nil || len(ef.Attachment.FileID) != 16 { continue } key, _ := uuidv7.BytesToString(ef.Attachment.FileID) reuse[key] = ef.FileAttachmentID olds = append(olds, oldRef{key: key, attID: ef.FileAttachmentID}) } } files, createdIDs, err := s.resolveFileLinks(ctx, row.ID, row.Files, reuse, row.UpdatedBy) if err != nil { return err } row.Files = files if err := s.repo.Update(ctx, row); err != nil { s.rollbackAttachments(ctx, createdIDs) return err } if err := s.syncAutomaticAOG(ctx, row); err != nil { return err } for _, o := range olds { if _, keep := newKeys[o.key]; keep { continue } _ = s.attachments.DeleteAttachment(ctx, o.attID) } return nil } func (s *FleetStatusService) WithUsage(u fleetStatusUsage) *FleetStatusService { s.usage = u return s } func (s *FleetStatusService) ReviseSummary(ctx context.Context, helicopterID []byte, in helicopterusage.ManualSummaryInput, actor []byte) error { if s.usage == nil || len(helicopterID) != 16 { return nil } return s.usage.ReviseManualTotals(ctx, helicopterID, in, actor) } func (s *FleetStatusService) WithHistory(h *FleetHistoryService) *FleetStatusService { s.history = h return s } func (s *FleetStatusService) recordAutoAOG(ctx context.Context, heli *helicopter.Helicopter, set bool, reason string) { if s.history == nil || heli == nil { return } action := fleethistory.ActionAOGCleared msg := "AOG cleared (auto: maintenance no longer due)" if set { action = fleethistory.ActionAOGSet msg = "AOG set (auto: maintenance next due)" if r := strings.TrimSpace(reason); r != "" { msg = "AOG set (auto) — " + r } } if err := s.history.Record(ctx, heli.ID, fleethistory.EntityHelicopter, heli.ID, action, msg, nil); err != nil { slog.WarnContext(ctx, "fleet status: record auto-AOG history failed", slog.Any("error", err)) } } func NewFleetStatusService(repo fleetstatus.Repository, attachments fleetStatusAttachments, helicopters fleetStatusHelicopters, complaints fleetStatusComplaints) *FleetStatusService { return &FleetStatusService{repo: repo, attachments: attachments, helicopters: helicopters, complaints: complaints} } func (s *FleetStatusService) syncAutomaticAOG(ctx context.Context, row *fleetstatus.FleetStatus) error { if s == nil || s.helicopters == nil || row == nil || len(row.HelicopterID) != 16 { return nil } heli, err := s.helicopters.GetByID(ctx, row.HelicopterID) if err != nil { return err } if heli == nil { idStr, _ := uuidv7.BytesToString(row.HelicopterID) slog.WarnContext(ctx, "fleet status: helicopter not found, skipping AOG sync", slog.String("helicopter_id", idStr)) return nil } now := time.Now().UTC() var evals []NextDueEvaluation if hasEligibleSchedule(row.MaintenanceSchedules) { summary, sErr := s.GetSummaryByHelicopterID(ctx, row.HelicopterID) if sErr != nil { return sErr } hours := ComponentHours{ Airframe: derefFloat(summary.Airframe.Hours), Engine1: derefFloat(summary.Engine1.Hours), Engine2: derefFloat(summary.Engine2.Hours), } evals, err = EvaluateNextDueSchedules(row.MaintenanceSchedules, hours, summary.Airframe.Hours != nil, heli.NR1, heli.NR2, now) if err != nil { return err } } loadComplaints := func() ([]complaint.Complaint, error) { if s.complaints == nil { return nil, nil } rows, _, lErr := s.complaints.ListByHelicopter(ctx, row.HelicopterID, 100, 0) return rows, lErr } grounded, reason, source, err := decideAutoAOG(evals, now, loadComplaints, func(c complaint.Complaint) string { return s.complaintAOGReason(ctx, c) }) if err != nil { return err } return s.applyAutoAOG(ctx, heli, grounded, reason, source) } func decideAutoAOG(evals []NextDueEvaluation, now time.Time, loadComplaints func() ([]complaint.Complaint, error), complaintReason func(complaint.Complaint) string) (grounded bool, reason string, source string, err error) { for i := range evals { if !evals[i].AOG { continue } cleanReason := strings.TrimSpace(evals[i].Reason) if cleanReason == "" { cleanReason = fmt.Sprintf("%s next due reached", evals[i].InspectionType) } return true, autoAOGReasonPrefixNextDue + " " + cleanReason, helicopter.AOGSourceAuto, nil } complaints, err := loadComplaints() if err != nil { return false, "", "", err } for i := range complaints { if complaints[i].IsGrounding(now) { return true, complaintReason(complaints[i]), helicopter.AOGSourceComplaint, nil } } return false, "", "", nil } func (s *FleetStatusService) applyAutoAOG(ctx context.Context, heli *helicopter.Helicopter, grounded bool, reason, source string) error { if grounded { wasAOG := heli.AirOnGround if !heli.AirOnGround || strings.TrimSpace(heli.AOGReason) != reason || strings.TrimSpace(heli.AOGSource) != source { heli.AirOnGround = true heli.AOGReason = reason heli.AOGSource = source if err := s.helicopters.Update(ctx, heli); err != nil { return err } if !wasAOG { s.recordAutoAOG(ctx, heli, true, reason) } } return nil } if isAutoAOG(heli) { heli.AirOnGround = false heli.AOGReason = "" heli.AOGSource = helicopter.AOGSourceUnknown if err := s.helicopters.Update(ctx, heli); err != nil { return err } s.recordAutoAOG(ctx, heli, false, "") } return nil } func hasEligibleSchedule(schedules []fleetstatus.MaintenanceSchedule) bool { for i := range schedules { if strings.TrimSpace(schedules[i].Due) != "" || strings.TrimSpace(schedules[i].Ext) != "" { return true } } return false } func (s *FleetStatusService) OverdueInspections(ctx context.Context, helicopterID []byte) ([]fleetstatus.OverdueInspection, error) { if s == nil || s.helicopters == nil || len(helicopterID) != 16 { return nil, nil } latest, err := s.repo.LatestByHelicopterIDs(ctx, [][]byte{helicopterID}) if err != nil { return nil, err } row := latest[string(helicopterID)] if row == nil || !hasEligibleSchedule(row.MaintenanceSchedules) { return nil, nil } heli, err := s.helicopters.GetByID(ctx, helicopterID) if err != nil || heli == nil { return nil, err } summary, err := s.GetSummaryByHelicopterID(ctx, helicopterID) if err != nil { return nil, err } hours := ComponentHours{ Airframe: derefFloat(summary.Airframe.Hours), Engine1: derefFloat(summary.Engine1.Hours), Engine2: derefFloat(summary.Engine2.Hours), } evals, err := EvaluateNextDueSchedules(row.MaintenanceSchedules, hours, summary.Airframe.Hours != nil, heli.NR1, heli.NR2, time.Now().UTC()) if err != nil { return nil, err } out := make([]fleetstatus.OverdueInspection, 0, len(evals)) for i := range evals { if !evals[i].AOG { continue } detail := strings.TrimSpace(evals[i].Reason) if detail == "" { detail = evals[i].InspectionType + " next due reached" } out = append(out, fleetstatus.OverdueInspection{InspectionType: evals[i].InspectionType, Detail: detail}) } return out, nil } func isAutoAOG(heli *helicopter.Helicopter) bool { if heli == nil || !heli.AirOnGround { return false } src := strings.TrimSpace(heli.AOGSource) if src == helicopter.AOGSourceAuto || src == helicopter.AOGSourceComplaint { return true } reason := strings.TrimSpace(heli.AOGReason) return strings.HasPrefix(reason, autoAOGReasonPrefixNextDue) || strings.HasPrefix(reason, autoAOGReasonPrefixComplaint) } func (s *FleetStatusService) complaintAOGReason(ctx context.Context, c complaint.Complaint) string { if c.MELSeverity > complaint.MELSeverityNonMEL { return autoAOGReasonPrefixComplaint + " MEL " + shareddto.MELLabel(c.MELSeverity) + " grace period expired" } if s.complaints != nil { if names, err := s.complaints.ResolveUserNames(ctx, []complaint.Complaint{c}); err == nil { if name := names[string(c.ReportedBy)]; name != "" { return autoAOGReasonPrefixComplaint + " NON-MEL complaint reported by " + name } } } return autoAOGReasonPrefixComplaint + " NON-MEL complaint reported" } func derefFloat(v *float64) float64 { if v == nil { return 0 } return *v } func (s *FleetStatusService) resolveFileLinks( ctx context.Context, fleetStatusID []byte, files []fleetstatus.FleetStatusFile, reuse map[string][]byte, actor []byte, ) ([]fleetstatus.FleetStatusFile, [][]byte, error) { refID, _ := uuidv7.BytesToString(fleetStatusID) out := make([]fleetstatus.FleetStatusFile, 0, len(files)) createdIDs := make([][]byte, 0, len(files)) for i := range files { f := files[i] f.FleetStatusID = fleetStatusID if len(f.FileID) != 16 { if len(f.FileAttachmentID) == 16 { out = append(out, f) } continue } key, _ := uuidv7.BytesToString(f.FileID) if attID, ok := reuse[key]; ok { f.FileAttachmentID = attID f.FileID = nil out = append(out, f) continue } cat := f.Category att, err := s.attachments.CreateAttachment(ctx, filemanager.CreateAttachmentParams{ FileID: f.FileID, RefType: FleetStatusFileRefType, RefID: refID, Category: &cat, ActorID: actor, }) if err != nil { s.rollbackAttachments(ctx, createdIDs) return nil, nil, err } createdIDs = append(createdIDs, att.ID) f.FileAttachmentID = att.ID f.FileID = nil out = append(out, f) } return out, createdIDs, nil } func (s *FleetStatusService) rollbackAttachments(ctx context.Context, ids [][]byte) { for _, id := range ids { _ = s.attachments.DeleteAttachment(ctx, id) } } func (s *FleetStatusService) Delete(ctx context.Context, id []byte, deletedBy []byte) error { return s.repo.Delete(ctx, id, deletedBy) } func (s *FleetStatusService) GetByID(ctx context.Context, id []byte) (*fleetstatus.FleetStatus, error) { return s.repo.GetByID(ctx, id) } func (s *FleetStatusService) List(ctx context.Context, filter, sort string, limit, offset int) ([]fleetstatus.FleetStatus, int64, error) { return s.repo.List(ctx, filter, normalizeSortByRules(sort, sortField("created_at"), sortField("updated_at"), ), limit, offset) } func (s *FleetStatusService) LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*fleetstatus.FleetStatus, error) { return s.repo.LatestByHelicopterIDs(ctx, helicopterIDs) } func (s *FleetStatusService) RecalculateAOGByHelicopter(ctx context.Context, helicopterID []byte) error { if s == nil || len(helicopterID) != 16 { return nil } latest, err := s.repo.LatestByHelicopterIDs(ctx, [][]byte{helicopterID}) if err != nil { return err } row := latest[string(helicopterID)] if row == nil { row = &fleetstatus.FleetStatus{HelicopterID: append([]byte(nil), helicopterID...)} } return s.syncAutomaticAOG(ctx, row) } func (s *FleetStatusService) RecalculateAllAOG(ctx context.Context) (int, error) { if s == nil { return 0, nil } ids, err := s.repo.HelicopterIDsWithFleetStatus(ctx) if err != nil { return 0, err } n := 0 failed := 0 for _, id := range ids { if err := s.RecalculateAOGByHelicopter(ctx, id); err != nil { failed++ idStr, _ := uuidv7.BytesToString(id) slog.WarnContext(ctx, "fleet status AOG recompute failed for helicopter", slog.String("helicopter_id", idStr), slog.Any("error", err)) continue } n++ } if failed > 0 { slog.WarnContext(ctx, "fleet status AOG recompute completed with failures", slog.Int("succeeded", n), slog.Int("failed", failed)) } return n, nil } func (s *FleetStatusService) RecalculateAOGByMission(ctx context.Context, missionID []byte) error { if s == nil || len(missionID) == 0 { return nil } helicopterID, err := s.repo.HelicopterIDByMissionID(ctx, missionID) if err != nil { return err } return s.RecalculateAOGByHelicopter(ctx, helicopterID) } func (s *FleetStatusService) MarkServiced(ctx context.Context, id []byte, servicedAt time.Time, actorID []byte) error { if servicedAt.IsZero() { servicedAt = time.Now().UTC() } if err := s.repo.MarkServiced(ctx, id, servicedAt.UTC(), actorID); err != nil { return err } row, err := s.repo.GetByID(ctx, id) if err != nil { return err } if row == nil { idStr, _ := uuidv7.BytesToString(id) slog.WarnContext(ctx, "fleet status: not found after mark-serviced, skipping AOG sync", slog.String("id", idStr)) return nil } return s.syncAutomaticAOG(ctx, row) } func (s *FleetStatusService) ListServiceHistoryByHelicopterID(ctx context.Context, helicopterID []byte, limit, offset int) ([]fleetstatus.FleetStatusServiceLog, int64, error) { return s.repo.ListServiceHistoryByHelicopterID(ctx, helicopterID, limit, offset) } func (s *FleetStatusService) GetSummaryByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.FleetStatusSummary, error) { if s.usage == nil || len(helicopterID) != 16 { return &shareddto.FleetStatusSummary{}, nil } usage, err := s.usage.GetByHelicopterID(ctx, helicopterID) if err != nil { return &shareddto.FleetStatusSummary{}, err } if usage == nil { return &shareddto.FleetStatusSummary{}, nil } hasE1, hasE2 := true, true if s.helicopters != nil { if heli, herr := s.helicopters.GetByID(ctx, helicopterID); herr == nil && heli != nil { hasE1, hasE2 = heli.NR1, heli.NR2 } } summary := buildFleetStatusSummary(usage, hasE1, hasE2) return &summary, nil } func EvaluateNextDueSchedules(schedules []fleetstatus.MaintenanceSchedule, hours ComponentHours, hasUsageData, hasEngine1, hasEngine2 bool, now time.Time) ([]NextDueEvaluation, error) { parser := nextdue.NewParser() out := make([]NextDueEvaluation, 0, len(schedules)) for i := range schedules { it := strings.TrimSpace(schedules[i].InspectionType) if (it == fleetstatus.InspectionTypeENG1 && !hasEngine1) || (it == fleetstatus.InspectionTypeENG2 && !hasEngine2) { out = append(out, NextDueEvaluation{InspectionType: it}) continue } current := hours.Airframe switch it { case fleetstatus.InspectionTypeENG1: current = hours.Engine1 case fleetstatus.InspectionTypeENG2: current = hours.Engine2 } ev, err := evaluateInspectionDue(parser, it, schedules[i].Due, schedules[i].Ext, current, hasUsageData, now) if err != nil { return nil, err } out = append(out, ev) } return out, nil } func evaluateInspectionDue(parser nextdue.Parser, inspectionType, dueRaw, extRaw string, currentHours float64, hasUsageData bool, now time.Time) (NextDueEvaluation, error) { due, err := parser.Parse("due", dueRaw) if err != nil { return NextDueEvaluation{}, err } ext, err := parser.Parse("ext", extRaw) if err != nil { return NextDueEvaluation{}, err } ev := NextDueEvaluation{InspectionType: strings.TrimSpace(inspectionType)} limit := due limitIsExt := false if !nextdue.IsUnset(ext) { limit, limitIsExt = ext, true } if nextdue.IsUnset(limit) { return ev, nil } switch limit.Kind { case nextdue.ValueKindHours: if !hasUsageData { return ev, nil } if limit.Hours != nil && currentHours > *limit.Hours { ev.AOG = true ev.Reason = nextDueOverdueReason(inspectionType, limitIsExt, true) } case nextdue.ValueKindDate: if limit.Date != nil && nextdue.NormalizeDate(now).After(nextdue.NormalizeDate(*limit.Date)) { ev.AOG = true ev.Reason = nextDueOverdueReason(inspectionType, limitIsExt, false) } } return ev, nil } func nextDueOverdueReason(inspectionType string, isExt, isHours bool) string { it := strings.TrimSpace(inspectionType) switch { case isExt && isHours: return it + " extended hours reached" case isExt: return it + " extended due date reached" case isHours: return it + " due hours reached" default: return it + " due date reached" } } func buildFleetStatusSummary(u *helicopterusage.HelicopterUsage, hasE1, hasE2 bool) shareddto.FleetStatusSummary { airframeHours := u.TotalAirframeHours airframeCycles := float64(u.TotalAirframeCycles) landings := float64(u.TotalLanding) flightReports := float64(u.TotalFlightReport) rotorBrakeCycles := float64(u.TotalRotorBrakeCycle) hookReleases := float64(u.TotalHookRelease) summary := shareddto.FleetStatusSummary{ Airframe: shareddto.FleetStatusSummaryAirframe{ Hours: &airframeHours, Cycles: &airframeCycles, }, Landings: &landings, FlightReports: &flightReports, RotorBrakeCycles: &rotorBrakeCycles, HookReleases: &hookReleases, } if hasE1 { summary.Engine1 = buildFleetStatusUsageEngine(u.TotalEngine1Hours, u.TotalEngine1GpcNgN1, u.TotalEngine1PtcNfN2, u.TotalEngine1Ccc) } if hasE2 { summary.Engine2 = buildFleetStatusUsageEngine(u.TotalEngine2Hours, u.TotalEngine2GpcNgN1, u.TotalEngine2PtcNfN2, u.TotalEngine2Ccc) } return summary } func buildFleetStatusUsageEngine(hours, gpcNgN1, ptcNfN2, ccc float64) shareddto.FleetStatusSummaryEngine { cccVal := ccc return shareddto.FleetStatusSummaryEngine{ Hours: &hours, GpcNgN1: &gpcNgN1, PtcNfN2: &ptcNfN2, Ccc: &cccVal, } }