init push
This commit is contained in:
40
internal/domain/mission/errors.go
Normal file
40
internal/domain/mission/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package mission
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrRequired = errors.New("mission required")
|
||||
ErrMissionIDRequired = errors.New("mission id required")
|
||||
ErrFileAttachmentIDRequired = errors.New("mission file_attachment_id required")
|
||||
ErrFlightIDRequired = errors.New("mission flight_id required")
|
||||
ErrTypeInvalid = errors.New("mission type invalid")
|
||||
ErrSubtypeInvalid = errors.New("mission subtype invalid")
|
||||
ErrSubtypeRequired = errors.New("mission subtype required")
|
||||
ErrSubtypeForbidden = errors.New("mission subtype forbidden")
|
||||
ErrDuplicateFlight = errors.New("mission duplicate flight")
|
||||
ErrScheduleMissing = errors.New("mission schedule not found")
|
||||
ErrBaseMissing = errors.New("mission base not found")
|
||||
ErrTwilightMissing = errors.New("mission twilight time not available")
|
||||
ErrCoordinatesInvalid = errors.New("base coordinates invalid")
|
||||
ErrLockedAfterFlightInspection = errors.New("mission locked by after_flight_inspection")
|
||||
)
|
||||
|
||||
const (
|
||||
ErrorMessageRequired = "mission is required"
|
||||
ErrorMessageMissionIDRequired = "mission_id is required"
|
||||
ErrorMessageFlightIDRequired = "flight_id is required"
|
||||
ErrorMessageTypeInvalid = "type is invalid or not found in mission category"
|
||||
ErrorMessageSubtypeRequired = "subtype_id is required when type is HEMS"
|
||||
ErrorMessageSubtypeInvalid = "subtype_id is invalid or not found"
|
||||
ErrorMessageSubtypeForbidden = "subtype_id is only allowed when type is HEMS"
|
||||
ErrorMessageDuplicateFlight = "mission for this flight already exists"
|
||||
ErrorMessageNotFound = "mission not found"
|
||||
ErrorMessageInvalidMissionID = "mission_id is invalid UUID"
|
||||
ErrorMessageInvalidFlightID = "flight_id is invalid UUID"
|
||||
ErrorMessageInvalidFlight = "flight_uuid is invalid UUID"
|
||||
ErrorMessageScheduleMissing = "mission schedule not found for the mission date"
|
||||
ErrorMessageBaseMissing = "mission base not found"
|
||||
ErrorMessageTwilightMissing = "civil twilight time could not be calculated for this base and date"
|
||||
ErrorMessageCoordinatesInvalid = "base coordinates are invalid"
|
||||
ErrorMessageLockedAfterFlightInspection = "mission is locked because after flight inspection already exists"
|
||||
)
|
||||
118
internal/domain/mission/model.go
Normal file
118
internal/domain/mission/model.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package mission
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/domain/flight"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Mission struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightID []byte `gorm:"type:binary(16);not null;index;column:flight_id"`
|
||||
FlightDataID []byte `gorm:"type:binary(16);index;column:flight_data_id"`
|
||||
MissionCategoryID []byte `gorm:"type:binary(16);index;column:mission_category_id"`
|
||||
SubtypeID []byte `gorm:"type:binary(16);index;column:subtype_id"`
|
||||
Type string `gorm:"type:varchar(20);not null;index;column:type"`
|
||||
Code string `gorm:"type:varchar(32);index;column:code"`
|
||||
Note string `gorm:"type:varchar(255);column:note"`
|
||||
StartTime string `gorm:"type:time;column:start_time"`
|
||||
EndTime string `gorm:"type:time;column:end_time"`
|
||||
FlightDataStatus string `gorm:"->;column:flight_data_status"`
|
||||
|
||||
Flight *flight.Flight `gorm:"foreignKey:FlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
MissionCategory *MissionCategory `gorm:"foreignKey:MissionCategoryID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
MissionSubCategory *MissionSubCategory `gorm:"foreignKey:SubtypeID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
Files []MissionFile `gorm:"foreignKey:MissionID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (Mission) TableName() string { return "missions" }
|
||||
|
||||
type ListFilter struct {
|
||||
Search string
|
||||
Sort string
|
||||
FlightID []byte
|
||||
FlightDataStatus string
|
||||
StartDate *time.Time
|
||||
EndDate *time.Time
|
||||
MissionType string
|
||||
PilotID []byte
|
||||
HelicopterID []byte
|
||||
}
|
||||
|
||||
type MissionFile struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
MissionID []byte `gorm:"type:binary(16);not null;index;column:mission_id"`
|
||||
FileAttachmentID []byte `gorm:"type:binary(16);not null;index;column:file_attachment_id"`
|
||||
|
||||
Mission *Mission `gorm:"foreignKey:MissionID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
FileAttachment *filemanager.Attachment `gorm:"foreignKey:FileAttachmentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (MissionFile) TableName() string { return "mission_files" }
|
||||
|
||||
func (m *MissionFile) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mission) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MissionCategory struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
CodeType string `gorm:"type:varchar(20);not null;uniqueIndex:uidx_mission_categories_code;column:code_type"`
|
||||
TypeName string `gorm:"type:varchar(128);not null;column:name"`
|
||||
|
||||
SubCategory []MissionSubCategory `gorm:"foreignKey:MissionCategoryID;references:ID"`
|
||||
}
|
||||
|
||||
func (MissionCategory) TableName() string { return "mission_categories" }
|
||||
|
||||
func (c *MissionCategory) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(c.ID) == 0 {
|
||||
c.ID = uuidv7.MustBytes()
|
||||
}
|
||||
c.CodeType = strings.ToUpper(strings.TrimSpace(c.CodeType))
|
||||
c.TypeName = strings.TrimSpace(c.TypeName)
|
||||
return nil
|
||||
}
|
||||
|
||||
type MissionSubCategory struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
MissionCategoryID []byte `gorm:"type:binary(16);not null;index;column:mission_category_id"`
|
||||
SubCodeType string `gorm:"type:varchar(32);not null;index;column:sub_type"`
|
||||
SubTypeName string `gorm:"type:varchar(128);not null;column:name"`
|
||||
TaskName string `gorm:"type:varchar(255);not null;column:task_name"`
|
||||
|
||||
MissionCategory *MissionCategory `gorm:"foreignKey:MissionCategoryID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (MissionSubCategory) TableName() string { return "mission_subcategories" }
|
||||
|
||||
func (s *MissionSubCategory) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(s.ID) == 0 {
|
||||
s.ID = uuidv7.MustBytes()
|
||||
}
|
||||
s.SubCodeType = strings.ToUpper(strings.TrimSpace(s.SubCodeType))
|
||||
s.SubTypeName = strings.TrimSpace(s.SubTypeName)
|
||||
s.TaskName = strings.TrimSpace(s.TaskName)
|
||||
return nil
|
||||
}
|
||||
25
internal/domain/mission/repository.go
Normal file
25
internal/domain/mission/repository.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package mission
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error
|
||||
Create(ctx context.Context, row *Mission) error
|
||||
CreateCategory(ctx context.Context, row *MissionCategory) error
|
||||
UpdateFlightDataIDByID(ctx context.Context, missionID, flightDataID, updatedBy []byte) error
|
||||
UpdateByID(ctx context.Context, missionID []byte, missionType string, missionCategoryID []byte, subtypeID []byte, note string, updatedBy []byte) error
|
||||
DeleteByID(ctx context.Context, missionID []byte, deletedBy []byte) error
|
||||
DeleteByFlightID(ctx context.Context, flightID []byte, deletedBy []byte) error
|
||||
ListByFlightID(ctx context.Context, flightID []byte) ([]Mission, error)
|
||||
ListByFlightIDs(ctx context.Context, flightIDs [][]byte) ([]Mission, error)
|
||||
GetByID(ctx context.Context, missionID []byte) (*Mission, error)
|
||||
GetByFlightID(ctx context.Context, flightID []byte) (*Mission, error)
|
||||
List(ctx context.Context, filter, sort string, flightID []byte, flightDataStatus string, limit, offset int) ([]Mission, int64, error)
|
||||
ListDatatable(ctx context.Context, filter ListFilter, limit, offset int) ([]Mission, int64, int64, error)
|
||||
ListCategoriesWithSubCategories(ctx context.Context) ([]MissionCategory, error)
|
||||
FindCategoryByCode(ctx context.Context, code string) (*MissionCategory, error)
|
||||
SubCategoryBelongsToCategory(ctx context.Context, subCategoryID, categoryID []byte) (bool, error)
|
||||
MaxCodeSeqByPrefix(ctx context.Context, prefix string) (int, error)
|
||||
AttachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error
|
||||
DetachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error
|
||||
}
|
||||
20
internal/domain/mission/service.go
Normal file
20
internal/domain/mission/service.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package mission
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *Mission) error
|
||||
CreateType(ctx context.Context, row *MissionCategory) error
|
||||
UpdateByID(ctx context.Context, missionID []byte, missionType string, subtypeID []byte, note string, updatedBy []byte) error
|
||||
DeleteByID(ctx context.Context, missionID []byte, deletedBy []byte) error
|
||||
DeleteByFlightID(ctx context.Context, flightID []byte, deletedBy []byte) error
|
||||
ListByFlightID(ctx context.Context, flightID []byte) ([]Mission, error)
|
||||
ListByFlightIDs(ctx context.Context, flightIDs [][]byte) ([]Mission, error)
|
||||
GetByID(ctx context.Context, missionID []byte) (*Mission, error)
|
||||
GetByFlightID(ctx context.Context, flightID []byte) (*Mission, error)
|
||||
List(ctx context.Context, filter, sort string, flightID []byte, flightDataStatus string, limit, offset int) ([]Mission, int64, error)
|
||||
ListDatatable(ctx context.Context, filter ListFilter, limit, offset int) ([]Mission, int64, int64, error)
|
||||
AttachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error
|
||||
DetachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error
|
||||
ListTypes(ctx context.Context) ([]MissionCategory, error)
|
||||
}
|
||||
115
internal/domain/mission/status.go
Normal file
115
internal/domain/mission/status.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mission
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sharedconst "wucher/internal/shared/const"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusCompleted = "completed"
|
||||
)
|
||||
|
||||
const (
|
||||
FormKeyFlightData = "flight_data"
|
||||
FormKeyPatientData = "patient_data"
|
||||
)
|
||||
|
||||
var missionTypeForms = map[string][]string{
|
||||
sharedconst.MissionTypeHEMS: []string{FormKeyFlightData, FormKeyPatientData},
|
||||
sharedconst.MissionTypeCAT: []string{FormKeyFlightData},
|
||||
sharedconst.MissionTypeSPO: []string{FormKeyFlightData},
|
||||
sharedconst.MissionTypeNCO: []string{FormKeyFlightData},
|
||||
}
|
||||
|
||||
type FormFacts map[string]string
|
||||
|
||||
type FormStatus struct {
|
||||
Key string
|
||||
Status string
|
||||
}
|
||||
|
||||
func BuildForms(missionType string, facts FormFacts) []FormStatus {
|
||||
keys := missionFormsForType(missionType)
|
||||
if len(keys) == 0 {
|
||||
keys = sortedFactKeys(facts)
|
||||
}
|
||||
out := make([]FormStatus, 0, len(keys))
|
||||
if len(keys) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
out = append(out, formStatus(key, facts[key]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func missionFormsForType(missionType string) []string {
|
||||
missionType = strings.ToUpper(strings.TrimSpace(missionType))
|
||||
if forms, ok := missionTypeForms[missionType]; ok && len(forms) > 0 {
|
||||
out := make([]string, len(forms))
|
||||
copy(out, forms)
|
||||
return out
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedFactKeys(facts FormFacts) []string {
|
||||
keys := make([]string, 0, len(facts))
|
||||
for key := range facts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// ComputeStatus derives the mission state from its forms so new forms can be
|
||||
// added later without reworking mission-level branching.
|
||||
func ComputeStatus(forms []FormStatus) string {
|
||||
if len(forms) == 0 {
|
||||
return StatusDraft
|
||||
}
|
||||
|
||||
completed := 0
|
||||
inProgress := 0
|
||||
for i := range forms {
|
||||
switch forms[i].Status {
|
||||
case StatusCompleted:
|
||||
completed++
|
||||
case StatusInProgress:
|
||||
inProgress++
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case completed == 0:
|
||||
if inProgress > 0 {
|
||||
return StatusInProgress
|
||||
}
|
||||
return StatusDraft
|
||||
case completed == len(forms):
|
||||
return StatusCompleted
|
||||
default:
|
||||
return StatusInProgress
|
||||
}
|
||||
}
|
||||
|
||||
func formStatus(key, status string) FormStatus {
|
||||
return FormStatus{Key: key, Status: normalizeFormStatus(status)}
|
||||
}
|
||||
|
||||
func normalizeFormStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case StatusCompleted:
|
||||
return StatusCompleted
|
||||
case StatusInProgress:
|
||||
return StatusInProgress
|
||||
default:
|
||||
return StatusDraft
|
||||
}
|
||||
}
|
||||
94
internal/domain/mission/status_test.go
Normal file
94
internal/domain/mission/status_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package mission
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestComputeStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forms []FormStatus
|
||||
want string
|
||||
}{
|
||||
{name: "no completed form is draft", forms: BuildForms("", FormFacts{FormKeyFlightData: StatusDraft}), want: StatusDraft},
|
||||
{name: "single in progress form is in progress", forms: BuildForms("", FormFacts{FormKeyFlightData: StatusInProgress}), want: StatusInProgress},
|
||||
{name: "all forms complete is complete", forms: BuildForms("", FormFacts{FormKeyFlightData: StatusCompleted}), want: StatusCompleted},
|
||||
{name: "partial completion is on progress", forms: []FormStatus{{Key: FormKeyFlightData, Status: StatusCompleted}, {Key: "patient_data", Status: StatusDraft}}, want: StatusInProgress},
|
||||
{name: "no forms defaults to draft", forms: nil, want: StatusDraft},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ComputeStatus(tc.forms); got != tc.want {
|
||||
t.Fatalf("ComputeStatus(%+v) = %q, want %q", tc.forms, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
missionTy string
|
||||
facts FormFacts
|
||||
want []FormStatus
|
||||
}{
|
||||
{
|
||||
name: "hems has flight data and patient data",
|
||||
missionTy: "HEMS",
|
||||
facts: FormFacts{FormKeyFlightData: StatusCompleted},
|
||||
want: []FormStatus{
|
||||
{Key: FormKeyFlightData, Status: StatusCompleted},
|
||||
{Key: FormKeyPatientData, Status: StatusDraft},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cat has flight data only",
|
||||
missionTy: "CAT",
|
||||
facts: FormFacts{FormKeyFlightData: StatusDraft},
|
||||
want: []FormStatus{{Key: FormKeyFlightData, Status: StatusDraft}},
|
||||
},
|
||||
{
|
||||
name: "spo has flight data only",
|
||||
missionTy: "SPO",
|
||||
facts: FormFacts{FormKeyFlightData: StatusCompleted},
|
||||
want: []FormStatus{{Key: FormKeyFlightData, Status: StatusCompleted}},
|
||||
},
|
||||
{
|
||||
name: "nco has flight data only",
|
||||
missionTy: "NCO",
|
||||
facts: FormFacts{FormKeyFlightData: StatusDraft},
|
||||
want: []FormStatus{{Key: FormKeyFlightData, Status: StatusDraft}},
|
||||
},
|
||||
{
|
||||
name: "unknown mission type falls back to provided facts",
|
||||
missionTy: "UNKNOWN",
|
||||
facts: FormFacts{FormKeyFlightData: StatusCompleted, FormKeyPatientData: StatusDraft},
|
||||
want: []FormStatus{
|
||||
{Key: FormKeyFlightData, Status: StatusCompleted},
|
||||
{Key: FormKeyPatientData, Status: StatusDraft},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
forms := BuildForms(tc.missionTy, tc.facts)
|
||||
if len(forms) != len(tc.want) {
|
||||
t.Fatalf("expected %d forms, got %d", len(tc.want), len(forms))
|
||||
}
|
||||
for i := range tc.want {
|
||||
if forms[i].Key != tc.want[i].Key || forms[i].Status != tc.want[i].Status {
|
||||
t.Fatalf("form %d = %+v, want %+v", i, forms[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormStatus(t *testing.T) {
|
||||
if got := formStatus(FormKeyFlightData, StatusCompleted); got.Status != StatusCompleted || got.Key != FormKeyFlightData {
|
||||
t.Fatalf("unexpected completed form: %+v", got)
|
||||
}
|
||||
if got := formStatus(FormKeyFlightData, StatusDraft); got.Status != StatusDraft {
|
||||
t.Fatalf("unexpected draft form: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user