init push
This commit is contained in:
153
internal/domain/complaint/model.go
Normal file
153
internal/domain/complaint/model.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package complaint
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightdomain "wucher/internal/domain/flight"
|
||||
helicopter "wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
MELSeverityNonMEL int8 = 0
|
||||
MELSeverityA int8 = 1
|
||||
MELSeverityB int8 = 2
|
||||
MELSeverityC int8 = 3
|
||||
MELSeverityD int8 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
StatusPendingMEL = "pending_mel"
|
||||
StatusActiveNonMEL = "active_non_mel"
|
||||
StatusActiveMEL = "active_mel"
|
||||
StatusServiced = "serviced"
|
||||
)
|
||||
|
||||
var melGraceDays = map[int8]int{
|
||||
MELSeverityA: 1,
|
||||
MELSeverityB: 3,
|
||||
MELSeverityC: 10,
|
||||
MELSeverityD: 120,
|
||||
}
|
||||
|
||||
type ListFilter struct {
|
||||
Search string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
Person string
|
||||
HelicopterID []byte
|
||||
FlightID []byte
|
||||
HoldItemsOnly bool
|
||||
}
|
||||
|
||||
func MELGraceDays(severity int8) int { return melGraceDays[severity] }
|
||||
|
||||
type Complaint struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
FlightID []byte `gorm:"type:binary(16);index;column:flight_id"`
|
||||
|
||||
Description string `gorm:"type:text;not null;column:description"`
|
||||
ReportedBy []byte `gorm:"type:binary(16);not null;column:reported_by"`
|
||||
ReportedAt time.Time `gorm:"type:datetime(3);not null;column:reported_at"`
|
||||
AircraftHoursAtReport *string `gorm:"type:varchar(20);column:aircraft_hours_at_report"`
|
||||
|
||||
MELSeverity int8 `gorm:"type:tinyint;not null;default:0;column:mel_severity"`
|
||||
MELClassifiedAt *time.Time `gorm:"type:datetime(3);column:mel_classified_at"`
|
||||
MELClassifiedBy []byte `gorm:"type:binary(16);column:mel_classified_by"`
|
||||
|
||||
IsNSR bool `gorm:"type:boolean;not null;default:false;column:is_nsr"`
|
||||
NSRDecidedAt *time.Time `gorm:"type:datetime(3);column:nsr_decided_at"`
|
||||
NSRDecidedBy []byte `gorm:"type:binary(16);column:nsr_decided_by"`
|
||||
NSRReason *string `gorm:"type:text;column:nsr_reason"`
|
||||
|
||||
ActionTaken *string `gorm:"type:text;column:action_taken"`
|
||||
AircraftHoursAtFix *string `gorm:"type:varchar(20);column:aircraft_hours_at_fix"`
|
||||
|
||||
FixedAt *time.Time `gorm:"type:datetime(3);column:fixed_at"`
|
||||
FixedByEASAID []byte `gorm:"type:binary(16);column:fixed_by_easa_id"`
|
||||
|
||||
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
|
||||
Flight *flightdomain.Flight `gorm:"foreignKey:FlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"type:datetime(3);column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"type:datetime(3);index;column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (Complaint) TableName() string { return "complaints" }
|
||||
|
||||
func (c *Complaint) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(c.ID) == 0 {
|
||||
c.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Complaint) Status() string {
|
||||
if c == nil {
|
||||
return StatusActiveNonMEL
|
||||
}
|
||||
if c.FixedAt != nil {
|
||||
return StatusServiced
|
||||
}
|
||||
// mel_severity is optional at creation; until it is classified (mel_classified_at set)
|
||||
// the complaint is "pending_mel" — its severity value (0) is a default, not "Non-MEL".
|
||||
if c.MELClassifiedAt == nil {
|
||||
return StatusPendingMEL
|
||||
}
|
||||
if c.MELSeverity > 0 {
|
||||
return StatusActiveMEL
|
||||
}
|
||||
return StatusActiveNonMEL
|
||||
}
|
||||
|
||||
func (c *Complaint) MELDeadline() *time.Time {
|
||||
if c == nil || c.MELSeverity <= 0 {
|
||||
return nil
|
||||
}
|
||||
grace := MELGraceDays(c.MELSeverity)
|
||||
if grace <= 0 || c.ReportedAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
// MEL grace is measured in days: A=1, B=3, C=10, D=120 days from report.
|
||||
deadline := c.ReportedAt.Add(time.Duration(grace) * 24 * time.Hour)
|
||||
return &deadline
|
||||
}
|
||||
|
||||
func (c *Complaint) IsMELAOG(now time.Time) bool {
|
||||
if c == nil || c.IsNSR {
|
||||
return false
|
||||
}
|
||||
deadline := c.MELDeadline()
|
||||
if deadline == nil {
|
||||
return false
|
||||
}
|
||||
return !now.Before(*deadline)
|
||||
}
|
||||
|
||||
// IsGrounding reports whether this complaint currently grounds the aircraft (AOG).
|
||||
// Returning to service requires a formal release: only a Non-Safety Release (NSR /
|
||||
// MEL deferral) or a signed EASA release (CRS, sets FixedAt) lifts the grounding.
|
||||
// Recording action_taken alone does NOT lift it — corrective work is not a release.
|
||||
//
|
||||
// A pending (not-yet-classified) complaint does NOT ground: an unassessed report is not
|
||||
// yet a grounding defect. Grounding starts at classification — NON-MEL grounds
|
||||
// immediately, MEL A–D grounds once its grace deadline lapses.
|
||||
func (c *Complaint) IsGrounding(now time.Time) bool {
|
||||
if c == nil || c.IsNSR || c.FixedAt != nil {
|
||||
return false
|
||||
}
|
||||
if c.MELClassifiedAt == nil {
|
||||
return false
|
||||
}
|
||||
if c.MELSeverity <= 0 {
|
||||
return true
|
||||
}
|
||||
return c.IsMELAOG(now)
|
||||
}
|
||||
141
internal/domain/complaint/model_test.go
Normal file
141
internal/domain/complaint/model_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package complaint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComplaintStatus(t *testing.T) {
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
want string
|
||||
}{
|
||||
{name: "nil complaint", row: nil, want: StatusActiveNonMEL},
|
||||
{name: "unclassified is pending", row: &Complaint{}, want: StatusPendingMEL},
|
||||
{name: "classified non-mel", row: &Complaint{MELSeverity: MELSeverityNonMEL, MELClassifiedAt: &now}, want: StatusActiveNonMEL},
|
||||
{name: "classified mel A", row: &Complaint{MELSeverity: MELSeverityA, MELClassifiedAt: &now}, want: StatusActiveMEL},
|
||||
{name: "fixed overrides mel", row: &Complaint{MELSeverity: MELSeverityA, MELClassifiedAt: &now, FixedAt: &now}, want: StatusServiced},
|
||||
{name: "fixed overrides default", row: &Complaint{FixedAt: &now}, want: StatusServiced},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.row.Status(); got != tc.want {
|
||||
t.Fatalf("Status() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplaintMELDeadline(t *testing.T) {
|
||||
base := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
wantNil bool
|
||||
wantTime time.Time
|
||||
}{
|
||||
{name: "non-mel", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base}, wantNil: true},
|
||||
// Grace is counted in days: A=1, D=120.
|
||||
{name: "mel A", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, wantTime: base.Add(24 * time.Hour)},
|
||||
{name: "mel D", row: &Complaint{MELSeverity: MELSeverityD, ReportedAt: base}, wantTime: base.Add(120 * 24 * time.Hour)},
|
||||
{name: "zero reported_at", row: &Complaint{MELSeverity: MELSeverityA}, wantNil: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.row.MELDeadline()
|
||||
if tc.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("MELDeadline() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || !got.Equal(tc.wantTime) {
|
||||
t.Fatalf("MELDeadline() = %v, want %v", got, tc.wantTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplaintIsGrounding(t *testing.T) {
|
||||
base := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
|
||||
fixed := base
|
||||
classified := base
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{name: "nsr never grounds", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, IsNSR: true}, now: base, want: false},
|
||||
{name: "fixed never grounds", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, FixedAt: &fixed}, now: base, want: false},
|
||||
{name: "pending (unclassified) does not ground", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base}, now: base, want: false},
|
||||
{name: "action taken alone does not lift grounding (needs EASA release)", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, ActionTaken: ptr("tightened")}, now: base, want: true},
|
||||
{name: "action taken with EASA fix lifts grounding", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, ActionTaken: ptr("tightened"), FixedAt: &fixed}, now: base, want: false},
|
||||
{name: "non-mel grounds immediately", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified}, now: base, want: true},
|
||||
{name: "mel within grace not grounding", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base, MELClassifiedAt: &classified}, now: base.Add(12 * time.Hour), want: false},
|
||||
{name: "mel past grace grounds", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base, MELClassifiedAt: &classified}, now: base.Add(2 * 24 * time.Hour), want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.row.IsGrounding(tc.now); got != tc.want {
|
||||
t.Fatalf("IsGrounding() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplaintIsMELAOG(t *testing.T) {
|
||||
base := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{name: "nsr", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base, IsNSR: true}, now: base.Add(time.Hour), want: false},
|
||||
{name: "non-mel has no deadline", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base}, now: base.Add(time.Hour), want: false},
|
||||
{name: "before deadline", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, now: base, want: false},
|
||||
{name: "at deadline", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, now: base.Add(24 * time.Hour), want: true},
|
||||
{name: "after deadline", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, now: base.Add(5 * 24 * time.Hour), want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.row.IsMELAOG(tc.now); got != tc.want {
|
||||
t.Fatalf("IsMELAOG() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
func TestComplaintTableNameAndBeforeCreate(t *testing.T) {
|
||||
if (Complaint{}).TableName() != "complaints" {
|
||||
t.Fatalf("unexpected table name")
|
||||
}
|
||||
c := &Complaint{}
|
||||
if err := c.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate err: %v", err)
|
||||
}
|
||||
if len(c.ID) == 0 {
|
||||
t.Fatal("BeforeCreate should set ID")
|
||||
}
|
||||
prev := string(c.ID)
|
||||
if err := c.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate err: %v", err)
|
||||
}
|
||||
if string(c.ID) != prev {
|
||||
t.Fatal("BeforeCreate must not overwrite an existing ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMELGraceDays(t *testing.T) {
|
||||
if MELGraceDays(MELSeverityA) != 1 || MELGraceDays(MELSeverityD) != 120 {
|
||||
t.Fatal("unexpected grace days")
|
||||
}
|
||||
if MELGraceDays(MELSeverityNonMEL) != 0 {
|
||||
t.Fatal("non-mel has no grace")
|
||||
}
|
||||
}
|
||||
19
internal/domain/complaint/repository.go
Normal file
19
internal/domain/complaint/repository.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package complaint
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *Complaint) error
|
||||
Update(ctx context.Context, row *Complaint) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Complaint, error)
|
||||
ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]Complaint, int64, error)
|
||||
List(ctx context.Context, filter ListFilter, sort string, limit, offset int) ([]Complaint, int64, error)
|
||||
HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error)
|
||||
ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
HelicopterHasUnactionedComplaint(ctx context.Context, helicopterID []byte) (bool, error)
|
||||
MarkFixedByHelicopter(ctx context.Context, helicopterID []byte, easaID []byte, actor []byte) error
|
||||
MarkFixedByComplaint(ctx context.Context, complaintID []byte, easaID []byte, actor []byte) error
|
||||
MarkFixedByFlight(ctx context.Context, flightID []byte, easaID []byte, actor []byte) error
|
||||
}
|
||||
20
internal/domain/complaint/service.go
Normal file
20
internal/domain/complaint/service.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package complaint
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *Complaint) error
|
||||
Update(ctx context.Context, row *Complaint) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Complaint, error)
|
||||
ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]Complaint, int64, error)
|
||||
List(ctx context.Context, filter ListFilter, sort string, limit, offset int) ([]Complaint, int64, error)
|
||||
HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error)
|
||||
ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
HelicopterHasUnactionedComplaint(ctx context.Context, helicopterID []byte) (bool, error)
|
||||
MarkFixedByHelicopter(ctx context.Context, helicopterID []byte, easaID []byte, actor []byte) error
|
||||
MarkFixedByComplaint(ctx context.Context, complaintID []byte, easaID []byte, actor []byte) error
|
||||
MarkFixedByFlight(ctx context.Context, flightID []byte, easaID []byte, actor []byte) error
|
||||
ResolveUserNames(ctx context.Context, complaints []Complaint) (map[string]string, error)
|
||||
}
|
||||
Reference in New Issue
Block a user