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,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"
)

View 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
}

View 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
}

View 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)
}

View 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
}
}

View 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)
}
}