package handlers import ( "context" "fmt" "log/slog" "strings" "time" actionsignoff "wucher/internal/domain/action_signoff" complaint "wucher/internal/domain/complaint" easarelease "wucher/internal/domain/easa_release" fleetstatus "wucher/internal/domain/fleet_status" "wucher/internal/domain/helicopter" "wucher/internal/domain/mcf" helicopterusage "wucher/internal/domain/helicopter_usage" "wucher/internal/shared/pkg/userctx" "wucher/internal/shared/pkg/uuidv7" requestdto "wucher/internal/transport/http/dto/request" responsedto "wucher/internal/transport/http/dto/response" shareddto "wucher/internal/transport/http/dto/shared" ) func fleetSummaryManualInput(s *requestdto.FleetStatusSummaryUpdate) helicopterusage.ManualSummaryInput { in := helicopterusage.ManualSummaryInput{ Landing: s.Landings, FlightReport: s.FlightReports, HookRelease: s.HookReleases, RotorBrakeCycle: s.RotorBrakeCycles, } if s.Airframe != nil { in.AirframeHours = s.Airframe.Hours in.AirframeCycles = s.Airframe.Cycles } if s.Engine1 != nil { in.Engine1Hours = s.Engine1.Hours in.Engine1GpcNgN1 = s.Engine1.GpcNgN1 in.Engine1PtcNfN2 = s.Engine1.PtcNfN2 in.Engine1Ccc = s.Engine1.Ccc } if s.Engine2 != nil { in.Engine2Hours = s.Engine2.Hours in.Engine2GpcNgN1 = s.Engine2.GpcNgN1 in.Engine2PtcNfN2 = s.Engine2.PtcNfN2 in.Engine2Ccc = s.Engine2.Ccc } return in } func mapFleetStatusPayload(attrs requestdto.FleetStatusCreateAttributes, actor []byte) (*fleetstatus.FleetStatus, error) { helicopterID, err := uuidv7.ParseString(strings.TrimSpace(attrs.HelicopterID)) if err != nil { return nil, err } row := &fleetstatus.FleetStatus{HelicopterID: helicopterID, CreatedBy: actor, UpdatedBy: actor} for i := range attrs.MaintenanceSchedules { item := attrs.MaintenanceSchedules[i] row.MaintenanceSchedules = append(row.MaintenanceSchedules, fleetstatus.MaintenanceSchedule{ FleetStatusID: row.ID, InspectionType: strings.TrimSpace(item.InspectionType), Type: strings.TrimSpace(item.Type), Due: strings.TrimSpace(item.Due), Ext: strings.TrimSpace(item.Ext), }) } for i := range attrs.Files { fileID, parseErr := uuidv7.ParseString(strings.TrimSpace(attrs.Files[i].FileID)) if parseErr != nil { return nil, parseErr } row.Files = append(row.Files, fleetstatus.FleetStatusFile{ FleetStatusID: row.ID, FileID: fileID, Category: strings.TrimSpace(attrs.Files[i].Category), IsMandatory: attrs.Files[i].IsMandatory != nil && *attrs.Files[i].IsMandatory, }) } return row, nil } func mergeFleetStatusPatch(existing *fleetstatus.FleetStatus, attrs requestdto.FleetStatusUpdateAttributes, actor []byte) (*fleetstatus.FleetStatus, error) { row := &fleetstatus.FleetStatus{ ID: existing.ID, HelicopterID: existing.HelicopterID, Status: existing.Status, ServicedAt: existing.ServicedAt, CreatedAt: existing.CreatedAt, CreatedBy: existing.CreatedBy, UpdatedBy: actor, } if attrs.HelicopterID != nil && strings.TrimSpace(*attrs.HelicopterID) != "" { helicopterID, err := uuidv7.ParseString(strings.TrimSpace(*attrs.HelicopterID)) if err != nil { return nil, err } row.HelicopterID = helicopterID } if attrs.MaintenanceSchedules != nil { row.MaintenanceSchedules = make([]fleetstatus.MaintenanceSchedule, 0, len(attrs.MaintenanceSchedules)) for i := range attrs.MaintenanceSchedules { item := attrs.MaintenanceSchedules[i] row.MaintenanceSchedules = append(row.MaintenanceSchedules, fleetstatus.MaintenanceSchedule{ FleetStatusID: row.ID, InspectionType: strings.TrimSpace(item.InspectionType), Type: strings.TrimSpace(item.Type), Due: strings.TrimSpace(item.Due), Ext: strings.TrimSpace(item.Ext), }) } } else { row.MaintenanceSchedules = existing.MaintenanceSchedules } if attrs.Files != nil { row.Files = make([]fleetstatus.FleetStatusFile, 0, len(attrs.Files)) for i := range attrs.Files { fileID, parseErr := uuidv7.ParseString(strings.TrimSpace(attrs.Files[i].FileID)) if parseErr != nil { return nil, parseErr } row.Files = append(row.Files, fleetstatus.FleetStatusFile{ FleetStatusID: row.ID, FileID: fileID, Category: strings.TrimSpace(attrs.Files[i].Category), IsMandatory: attrs.Files[i].IsMandatory != nil && *attrs.Files[i].IsMandatory, }) } } else { row.Files = existing.Files } return row, nil } func fleetStatusMaintenanceSchedules(schedules []fleetstatus.MaintenanceSchedule) []shareddto.MaintenanceScheduleAttributes { byType := make(map[string][]fleetstatus.MaintenanceSchedule, len(schedules)) for i := range schedules { t := schedules[i].InspectionType byType[t] = append(byType[t], schedules[i]) } out := make([]shareddto.MaintenanceScheduleAttributes, 0, len(fleetstatus.InspectionTypeOrder)+len(schedules)) for _, t := range fleetstatus.InspectionTypeOrder { items := byType[t] if len(items) == 0 { out = append(out, shareddto.MaintenanceScheduleAttributes{InspectionType: t}) continue } for i := range items { out = append(out, maintenanceScheduleAttributes(items[i])) } delete(byType, t) } for i := range schedules { items, ok := byType[schedules[i].InspectionType] if !ok { continue } for j := range items { out = append(out, maintenanceScheduleAttributes(items[j])) } delete(byType, schedules[i].InspectionType) } return out } func maintenanceScheduleAttributes(s fleetstatus.MaintenanceSchedule) shareddto.MaintenanceScheduleAttributes { return shareddto.MaintenanceScheduleAttributes{ InspectionType: s.InspectionType, Type: s.Type, Due: s.Due, Ext: s.Ext, } } func fleetStatusFiles(ctx context.Context, storage fileManagerDownloadURLStorage, files []fleetstatus.FleetStatusFile) []responsedto.FleetStatusFile { out := make([]responsedto.FleetStatusFile, 0, len(files)) for i := range files { item := responsedto.FleetStatusFile{ ID: idString(files[i].ID), FileAttachmentID: idString(files[i].FileAttachmentID), Category: files[i].Category, IsMandatory: files[i].IsMandatory, } if files[i].Attachment != nil && files[i].Attachment.File != nil { file := files[i].Attachment.File item.Name = strings.TrimSpace(file.Name) asset := resolveFileManagerDownloadURLs(ctx, storage, file) item.DownloadURL = strings.TrimSpace(asset.OriginalURL) item.ThumbnailDownloadURL = strings.TrimSpace(asset.ThumbnailURL) item.SizeBytes = int64Ptr(asset.OriginalSizeBytes) } out = append(out, item) } return out } func fleetStatusResource(ctx context.Context, svc fleetstatus.Service, storage fileManagerDownloadURLStorage, row *fleetstatus.FleetStatus, helicopterStatus string) responsedto.FleetStatusResource { res := responsedto.FleetStatusResource{Type: "fleet_status", ID: idString(row.ID), Attributes: responsedto.FleetStatusAttributes{Helicopter: fleetStatusHelicopterResource(row.Helicopter, helicopterStatus)}} res.Attributes.Status = row.Status res.Attributes.ServicedAt = timeStringOrEmpty(row.ServicedAt) res.Attributes.Summary = fleetStatusSummary(ctx, svc, row) res.Attributes.MaintenanceSchedules = fleetStatusMaintenanceSchedules(row.MaintenanceSchedules) res.Attributes.Files = fleetStatusFiles(ctx, storage, row.Files) res.Attributes.CreatedAt = row.CreatedAt.UTC().Format(time.RFC3339) res.Attributes.CreatedBy = bytesUUIDToString(row.CreatedBy) res.Attributes.UpdatedAt = row.UpdatedAt.UTC().Format(time.RFC3339) res.Attributes.UpdatedBy = bytesUUIDToString(row.UpdatedBy) return res } func (h *FleetStatusHandler) FMReportFleetStatus(ctx context.Context, helicopterID []byte) *responsedto.FleetStatusResource { if h == nil || h.svc == nil || len(helicopterID) != 16 { return nil } latest, err := h.svc.LatestByHelicopterIDs(ctx, [][]byte{helicopterID}) if err != nil { return nil } row := latest[string(helicopterID)] if row == nil { return nil } status := h.deriveHelicopterStatus(ctx, row.Helicopter, row.HelicopterID) res := fleetStatusResource(ctx, h.svc, h.storage, row, status) res.Attributes.EASARelease = resolveEASARelease(ctx, h.easaSvc, row.HelicopterID) return &res } func fleetStatusDetailResource(ctx context.Context, h *FleetStatusHandler, row *fleetstatus.FleetStatus, helicopterStatus string) responsedto.FleetStatusResource { res := fleetStatusResource(ctx, h.svc, h.storage, row, helicopterStatus) if h == nil { return res } // Current active flight (when in use / booked) so the fleet page can create an EASA // release scoped to the flight. if af, err := h.helicopterSvc.ActiveFlightIDByAircraftIDs(ctx, [][]byte{row.HelicopterID}); err == nil { if fid := af[string(row.HelicopterID)]; len(fid) == 16 && res.Attributes.Helicopter != nil { res.Attributes.Helicopter.FlightID = idString(fid) } } // Surface open defects regardless of the derived status label: a complaint must show // even when the aircraft reads MCF/available. MCF is checked before AOG in // DeriveStatus (so a grounding complaint can be masked by MCF), and NSR-deferred / // MEL-within-grace items don't ground at all yet are still open hold items. // Every OPEN defect, plus the FIXED defects from this helicopter's latest flight (so a // just-resolved defect stays visible as the last state until a newer flight reports). // Same rule as the fleet list — one source of truth in OpenByHelicopterIDs. complaints := make([]complaint.Complaint, 0) if rows, err := h.complaintSvc.OpenByHelicopterIDs(ctx, [][]byte{row.HelicopterID}); err == nil { complaints = rows } if len(complaints) > 0 { res.Attributes.Complaints = mapComplaintListToFleetStatusDTO(ctx, h.complaintSvc, h.signoffSvc, complaints) } // The release form + status reasons accompany a defect (or a non-complaint AOG such // as a maintenance next-due); skip the extra lookups for a clean, non-AOG aircraft. if len(complaints) > 0 || helicopterStatus == helicopter.StatusAOG { res.Attributes.EASARelease = resolveEASARelease(ctx, h.easaSvc, row.HelicopterID) if row.Helicopter != nil { overdue, oErr := h.svc.OverdueInspections(ctx, row.HelicopterID) if oErr != nil { slog.WarnContext(ctx, "fleet status: overdue inspections lookup failed", slog.Any("error", oErr)) } res.Attributes.Helicopter.StatusReasons = buildHelicopterStatusReasons(row.Helicopter, complaints, overdue, time.Now().UTC()) } } return res } func mapComplaintListToFleetStatusDTO(ctx context.Context, svc complaintLister, signoffSvc complaintSignoffLookup, rows []complaint.Complaint) []shareddto.FleetStatusComplaint { out := make([]shareddto.FleetStatusComplaint, 0, len(rows)) names := map[string]string{} if svc != nil && len(rows) > 0 { if resolved, err := svc.ResolveUserNames(ctx, rows); err == nil { names = resolved } } signoffs := resolveSignoffsByComplaint(ctx, signoffSvc, rows, names) for i := range rows { out = append(out, mapComplaintToFleetStatusDTO(ctx, &rows[i], names, signoffs)) } return out } func mapComplaintToFleetStatusDTO(ctx context.Context, c *complaint.Complaint, names map[string]string, signoffs map[string]*actionsignoff.ActionSignoff) shareddto.FleetStatusComplaint { if c == nil { return shareddto.FleetStatusComplaint{} } signoff := signoffs[string(c.ID)] dto := shareddto.FleetStatusComplaint{ ID: idString(c.ID), FlightID: idString(c.FlightID), Description: c.Description, Status: c.Status(), ReportedAt: c.ReportedAt.UTC().Format(time.RFC3339), AircraftHoursAtReport: c.AircraftHoursAtReport, MEL: shareddto.FleetStatusComplaintMEL{Severity: c.MELSeverity}, NSR: shareddto.FleetStatusComplaintNSR{IsNSR: c.IsNSR}, Action: shareddto.FleetStatusComplaintAction{Taken: c.ActionTaken, AircraftHoursAtFix: c.AircraftHoursAtFix}, } dto.ReportedBy = idString(c.ReportedBy) dto.ReportedByName = names[string(c.ReportedBy)] dto.ReportedByShort = userctx.GetShortName(ctx, c.ReportedBy) dto.MEL.ClassifiedBy = idString(c.MELClassifiedBy) dto.MEL.ClassifiedByName = names[string(c.MELClassifiedBy)] dto.MEL.ClassifiedByShort = userctx.GetShortName(ctx, c.MELClassifiedBy) dto.MEL.ClassifiedAt = timePtrOrNil(c.MELClassifiedAt) // Only label severity once classified — pending complaints (mel_classified_at NULL) // keep severity 0 as a default, which is not a "Non-MEL" decision. if c.MELClassifiedAt != nil { if label := complaintSeverityLabel(c.MELSeverity); label != "" { dto.MEL.SeverityLabel = &label } } if dl := c.MELDeadline(); dl != nil { v := dl.UTC().Format(time.RFC3339) dto.MEL.Deadline = &v } dto.NSR.DecidedAt = timePtrOrNil(c.NSRDecidedAt) dto.NSR.DecidedBy = idString(c.NSRDecidedBy) dto.NSR.DecidedByName = names[string(c.NSRDecidedBy)] dto.NSR.DecidedByShort = userctx.GetShortName(ctx, c.NSRDecidedBy) dto.NSR.Reason = c.NSRReason if signoff != nil { dto.Action.SignedAt = timePtrOrNil(signoff.SignedAt) dto.Action.SignedBy = idString(signoff.SignedBy) dto.Action.SignedByName = names[string(signoff.SignedBy)] dto.Action.SignedByShort = userctx.GetShortName(ctx, signoff.SignedBy) } return dto } func resolveEASARelease(ctx context.Context, svc easaReleaseLister, helicopterID []byte) *shareddto.FleetStatusEASARelease { if svc == nil || len(helicopterID) != 16 { return nil } rel, err := svc.GetLatestByHelicopter(ctx, helicopterID) if err == nil && rel != nil { return mapEASAReleaseToDTO(ctx, rel) } return nil } func mapEASAReleaseToDTO(ctx context.Context, r *easarelease.EASARelease) *shareddto.FleetStatusEASARelease { if r == nil { return nil } dto := &shareddto.FleetStatusEASARelease{ ID: idString(r.ID), IsSigned: r.IsSigned(), EASADate: timeDatePtrOrNil(r.EASADate), EASAACHours: r.EASAACHours, EASALocation: r.EASALocation, EASAWONo: r.EASAWONo, EASAMaintManualRevAirframe: r.EASAMaintManualRevAirframe, EASAMaintManualRevEngine: r.EASAMaintManualRevEngine, EASASignerID: r.EASASignerID, EASAPermitNo: r.EASAPermitNo, SignedAt: timePtrOrNil(r.SignedAt), SignedBy: idString(r.SignedBy), SignedByName: userctx.GetDisplayName(ctx, r.SignedBy), SignedByShort: userctx.GetShortName(ctx, r.SignedBy), MissingSignFields: r.MissingSignFields(), } return dto } func buildHelicopterStatusReasons(h *helicopter.Helicopter, complaints []complaint.Complaint, overdue []fleetstatus.OverdueInspection, now time.Time) []shareddto.FleetStatusHelicopterReason { reasons := make([]shareddto.FleetStatusHelicopterReason, 0) if h != nil && h.AirOnGround { src := strings.TrimSpace(h.AOGSource) if src != helicopter.AOGSourceComplaint && src != helicopter.AOGSourceAuto { detail := strings.TrimSpace(h.AOGReason) if detail == "" { detail = "Manual AOG" } reasons = append(reasons, shareddto.FleetStatusHelicopterReason{Code: shareddto.ReasonCodeManualAOG, Detail: detail}) } } for i := range complaints { c := complaints[i] if !c.IsGrounding(now) { continue } short := shortenText(c.Description, 40) if c.MELSeverity > 0 { reason := shareddto.FleetStatusHelicopterReason{ Code: shareddto.ReasonCodeMELExpired, Detail: fmt.Sprintf("MEL %s for %q overdue", complaintSeverityLabel(c.MELSeverity), short), } if deadline := c.MELDeadline(); deadline != nil { since := deadline.UTC().Format(time.RFC3339) reason.Since = &since } reasons = append(reasons, reason) continue } since := c.ReportedAt.UTC().Format(time.RFC3339) reasons = append(reasons, shareddto.FleetStatusHelicopterReason{ Code: shareddto.ReasonCodeNonMELPending, Detail: fmt.Sprintf("NON-MEL %q pending technician sign", short), Since: &since, }) } for i := range overdue { reasons = append(reasons, shareddto.FleetStatusHelicopterReason{ Code: shareddto.ReasonCodeInspectionOverdue, Detail: overdue[i].Detail, }) } if len(reasons) == 0 { return nil } return reasons } func complaintSeverityLabel(severity int8) string { switch severity { case complaint.MELSeverityNonMEL: return "NON-MEL" case complaint.MELSeverityA: return "A" case complaint.MELSeverityB: return "B" case complaint.MELSeverityC: return "C" case complaint.MELSeverityD: return "D" default: return "" } } func shortenText(v string, n int) string { v = strings.TrimSpace(v) if n <= 0 || len(v) <= n { return v } return v[:n] } func timePtrOrNil(v *time.Time) *string { if v == nil || v.IsZero() { return nil } s := v.UTC().Format(time.RFC3339) return &s } func timeDatePtrOrNil(v *time.Time) *string { if v == nil || v.IsZero() { return nil } s := v.UTC().Format("2006-01-02") return &s } func fleetStatusListResourceWithSummaryCache(ctx context.Context, svc fleetstatus.Service, storage fileManagerDownloadURLStorage, row *fleetstatus.FleetStatus, helicopterStatus string, cache map[string]shareddto.FleetStatusSummary) responsedto.FleetStatusResource { if row == nil { return responsedto.FleetStatusResource{} } resource := responsedto.FleetStatusResource{Type: "fleet_status", ID: idString(row.ID), Attributes: responsedto.FleetStatusAttributes{Helicopter: fleetStatusHelicopterResource(row.Helicopter, helicopterStatus)}} resource.Attributes.Status = row.Status resource.Attributes.ServicedAt = timeStringOrEmpty(row.ServicedAt) resource.Attributes.Summary = fleetStatusSummaryWithCache(ctx, svc, row, cache) resource.Attributes.MaintenanceSchedules = fleetStatusMaintenanceSchedules(row.MaintenanceSchedules) resource.Attributes.Files = fleetStatusFiles(ctx, storage, row.Files) resource.Attributes.CreatedAt = row.CreatedAt.UTC().Format(time.RFC3339) resource.Attributes.CreatedBy = bytesUUIDToString(row.CreatedBy) resource.Attributes.UpdatedAt = row.UpdatedAt.UTC().Format(time.RFC3339) resource.Attributes.UpdatedBy = bytesUUIDToString(row.UpdatedBy) return resource } func fleetStatusSummaryWithCache(ctx context.Context, svc fleetstatus.Service, row *fleetstatus.FleetStatus, cache map[string]shareddto.FleetStatusSummary) shareddto.FleetStatusSummary { if svc == nil || row == nil || len(row.HelicopterID) != 16 { return shareddto.FleetStatusSummary{} } key := idString(row.HelicopterID) if cache != nil { if cached, ok := cache[key]; ok { return cached } } summary := fleetStatusSummary(ctx, svc, row) if cache != nil { cache[key] = summary } return summary } func fleetStatusSummary(ctx context.Context, svc fleetstatus.Service, row *fleetstatus.FleetStatus) shareddto.FleetStatusSummary { if svc == nil || row == nil || len(row.HelicopterID) != 16 { return shareddto.FleetStatusSummary{} } summary, err := svc.GetSummaryByHelicopterID(ctx, row.HelicopterID) if err != nil || summary == nil { return shareddto.FleetStatusSummary{} } return *summary } func fleetStatusServiceLogResource(row *fleetstatus.FleetStatusServiceLog) responsedto.FleetStatusServiceLogResource { return responsedto.FleetStatusServiceLogResource{ Type: "fleet_status_service_log", ID: idString(row.ID), Attributes: responsedto.FleetStatusServiceLogAttributes{ FleetStatusID: idString(row.FleetStatusID), HelicopterID: idString(row.HelicopterID), Helicopter: fleetStatusHelicopterResource(row.Helicopter, ""), FleetStatusStatus: fleetstatus.StatusServiced, InspectionType: row.InspectionType, Type: row.Type, Due: row.Due, Ext: row.Ext, ServicedAt: row.ServicedAt.UTC().Format(time.RFC3339), CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), CreatedBy: bytesUUIDToString(row.CreatedBy), }, } } func fleetStatusHelicopterResource(row *helicopter.Helicopter, status string) *shareddto.FleetStatusHelicopter { if row == nil { return nil } return &shareddto.FleetStatusHelicopter{ ID: idString(row.ID), Designation: row.Designation, Identifier: row.Identifier, Type: row.Type, Status: status, } } // openComplaintsByHelicopter loads every open (fixed_at IS NULL) complaint for the fleet // in a single query and groups them per helicopter (raw-bytes string key), so the list // view can fill the Complaints column for each aircraft without an N+1. Returns an empty // map on a nil service or query error. func openComplaintsByHelicopter(ctx context.Context, svc complaintLister, ids [][]byte) map[string][]complaint.Complaint { out := map[string][]complaint.Complaint{} if svc == nil || len(ids) == 0 { return out } rows, err := svc.OpenByHelicopterIDs(ctx, ids) if err != nil { slog.WarnContext(ctx, "fleet status list: open complaints lookup failed", slog.Any("error", err)) return out } for i := range rows { k := string(rows[i].HelicopterID) out[k] = append(out[k], rows[i]) } return out } func (h *FleetStatusHandler) fleetStatusResourcesForHelicopters(ctx context.Context, helis []helicopter.Helicopter) ([]responsedto.FleetStatusResource, error) { ids := make([][]byte, 0, len(helis)) for i := range helis { ids = append(ids, helis[i].ID) } latest, err := h.svc.LatestByHelicopterIDs(ctx, ids) if err != nil { return nil, err } inUse, err := h.helicopterSvc.InUseByAircraftIDs(ctx, ids) if err != nil { slog.WarnContext(ctx, "fleet status list: in-use lookup failed", slog.Any("error", err)) } // Current active flight per in-use aircraft — exposed so the fleet page can create an // EASA release scoped to the flight (e.g. a standalone release for a booked aircraft). activeFlight, err := h.helicopterSvc.ActiveFlightIDByAircraftIDs(ctx, ids) if err != nil { slog.WarnContext(ctx, "fleet status list: active-flight lookup failed", slog.Any("error", err)) } grounded := groundingHelicopterSet(ctx, h.complaintSvc, ids, time.Now().UTC()) mcfPending := mcfPendingHelicopterSet(ctx, h.mcfSvc, ids) mcfLatest, err := h.mcfSvc.LatestByHelicopterIDs(ctx, ids) if err != nil { slog.WarnContext(ctx, "fleet status list: latest-mcf lookup failed", slog.Any("error", err)) mcfLatest = map[string]*mcf.MaintenanceCheckFlight{} } // One batch query for the whole fleet's open defects, grouped per helicopter, so the // Complaints column can be filled for every aircraft that has one — not just AOG ones. openByHeli := openComplaintsByHelicopter(ctx, h.complaintSvc, ids) cache := make(map[string]shareddto.FleetStatusSummary, len(helis)) render := fleetStatusRender{svc: h.svc, storage: h.storage, cache: cache} now := time.Now().UTC() out := make([]responsedto.FleetStatusResource, 0, len(helis)) for i := range helis { heli := &helis[i] key := string(heli.ID) status := heli.DeriveStatus(helicopter.StatusInput{IsBook: inUse[key], IsMCF: mcfPending[key], ComplaintGrounded: grounded[key]}) res := fleetStatusResourceForHelicopter(ctx, render, heli, latest[key], status) if fid := activeFlight[key]; len(fid) == 16 && res.Attributes.Helicopter != nil { res.Attributes.Helicopter.FlightID = idString(fid) } res.Attributes.MCF = buildFleetMCF(ctx, mcfLatest[key], res.Attributes.Summary) open := openByHeli[key] if len(open) > 0 { res.Attributes.Complaints = mapComplaintListToFleetStatusDTO(ctx, h.complaintSvc, h.signoffSvc, open) } // Load the release form + status reasons when there is a defect, or a non-complaint // AOG (e.g. maintenance next-due). Clean, non-AOG aircraft skip the extra lookups. if len(open) > 0 || status == helicopter.StatusAOG { res.Attributes.EASARelease = resolveEASARelease(ctx, h.easaSvc, heli.ID) overdue, oErr := h.svc.OverdueInspections(ctx, heli.ID) if oErr != nil { slog.WarnContext(ctx, "fleet status: overdue inspections lookup failed", slog.Any("error", oErr)) } if res.Attributes.Helicopter != nil { res.Attributes.Helicopter.StatusReasons = buildHelicopterStatusReasons(heli, open, overdue, now) } } out = append(out, res) } return out, nil } type fleetStatusRender struct { svc fleetstatus.Service storage fileManagerDownloadURLStorage cache map[string]shareddto.FleetStatusSummary } func fleetStatusResourceForHelicopter(ctx context.Context, r fleetStatusRender, heli *helicopter.Helicopter, latest *fleetstatus.FleetStatus, status string) responsedto.FleetStatusResource { if latest != nil { res := fleetStatusListResourceWithSummaryCache(ctx, r.svc, r.storage, latest, status, r.cache) res.Attributes.Helicopter = fleetStatusHelicopterResource(heli, status) return res } return responsedto.FleetStatusResource{ Type: "fleet_status", Attributes: responsedto.FleetStatusAttributes{ Helicopter: fleetStatusHelicopterResource(heli, status), MaintenanceSchedules: fleetStatusMaintenanceSchedules(nil), Files: fleetStatusFiles(ctx, r.storage, nil), }, } } // buildFleetMCF renders the helicopter's MCF for the fleet page. When there is a real // MCF it uses its values (sign = completed). When there is none it returns a placeholder // (empty id, unsigned) pre-filled from the aircraft's current airframe hours / landings. func buildFleetMCF(ctx context.Context, row *mcf.MaintenanceCheckFlight, summary shareddto.FleetStatusSummary) *shareddto.FleetStatusMCF { // The fleet shows the helicopter's LATEST MCF. When none exists — or the MCF is still a // draft — af_hours / landing_count pre-fill from the aircraft's current (flight-derived) // totals (like legacy). A signed (completed) MCF stays visible with its own recorded // snapshot + signer, until a new MCF replaces it. // af_hours / landing_count are the SIGNED check-flight values — only present once the MCF // is signed. Unsigned states (none / draft / cancelled) omit them. out := &shareddto.FleetStatusMCF{} if row == nil { return out } // Surface the record (id + date) for any real MCF — including a cancelled one — so the // fleet distinguishes it from "no MCF". out.ID = idString(row.ID) if row.Date != nil && !row.Date.IsZero() { d := row.Date.UTC().Format("2006-01-02") out.Date = &d } // A cancelled MCF ("tidak jadi") releases the status — active:false, sign:false — but the // record stays visible (id/date) so it shows as a cancelled MCF, not "no MCF". if row.IsCancelled() { return out } out.Sign = row.IsComplete() // Active only while the MCF is an open draft. Once signed it is considered done // (regardless of pass/fail) and the helicopter leaves MCF → not active. out.Active = row.IsPending() if row.IsComplete() { if af := strings.TrimSpace(row.AFHours); af != "" { out.AFHours = &af } lc := row.LandingCount out.LandingCount = &lc out.SignedBy = idString(row.CompletedBy) if n := userctx.GetDisplayName(ctx, row.CompletedBy); n != "" { out.SignedByName = &n } if s := userctx.GetShortName(ctx, row.CompletedBy); s != "" { out.SignedByShort = &s } } return out } func (h *FleetStatusHandler) deriveHelicopterStatus(ctx context.Context, heli *helicopter.Helicopter, helicopterID []byte) string { if heli == nil { return "" } inUse := false if len(helicopterID) == 16 { got, err := h.helicopterSvc.IsInUse(ctx, helicopterID) if err != nil { slog.WarnContext(ctx, "fleet status: in-use lookup failed", slog.Any("error", err), slog.String("helicopter_id", idString(helicopterID))) } inUse = got } grounded := false mcfPending := false if len(helicopterID) == 16 { grounded = groundingHelicopterSet(ctx, h.complaintSvc, [][]byte{helicopterID}, time.Now().UTC())[string(helicopterID)] mcfPending = mcfPendingHelicopterSet(ctx, h.mcfSvc, [][]byte{helicopterID})[string(helicopterID)] } return heli.DeriveStatus(helicopter.StatusInput{IsBook: inUse, IsMCF: mcfPending, ComplaintGrounded: grounded}) }