init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,664 @@
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,
}
}