716 lines
28 KiB
Go
716 lines
28 KiB
Go
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})
|
|
}
|