diff --git a/api/handlers/dispute/dispute.go b/api/handlers/dispute/dispute.go index 0f8e680b..b2c24d4d 100644 --- a/api/handlers/dispute/dispute.go +++ b/api/handlers/dispute/dispute.go @@ -544,13 +544,28 @@ func (h Dispute) CreateDispute(c *gin.Context) { } //asssign experts to dispute - selected, err := h.Model.AssignExpertsToDispute(disputeId) + // selected, err := h.Model.AssignExpertsToDispute(disputeId) + // if err != nil { + // logger.WithError(err).Error("Error assigning experts to dispute") + // c.JSON(http.StatusInternalServerError, models.Response{Error: "Error assigning experts to dispute"}) + // return + // } + // logger.Info("Assigned experts", selected) + + //assign using mediator assignment algorithm + expertIds, err := h.MediatorAssignment.AssignMediator(3, int(disputeId)) + if err != nil { + logger.WithError(err).Error("Error assigning experts to dispute") + c.JSON(http.StatusInternalServerError, models.Response{Error: "Error assigning experts to dispute"}) + return + } + + err = h.Model.AssignExpertswithDisputeAndExpertIDs(disputeId, expertIds) if err != nil { logger.WithError(err).Error("Error assigning experts to dispute") c.JSON(http.StatusInternalServerError, models.Response{Error: "Error assigning experts to dispute"}) return } - logger.Info("Assigned experts", selected) // Respond with success message if !defaultAccount { @@ -742,6 +757,29 @@ func (h Dispute) ExpertObjectionsReview(c *gin.Context) { return } + if *req.Status == models.ObjectionSustained { + disputeId, err := h.Model.GetDisputeIDByTicketID(int64(objectionIdInt)) + if err != nil { + logger.WithError(err).Error("Error getting dispute ID") + c.JSON(http.StatusInternalServerError, models.Response{Error: "Error getting dispute ID"}) + return + } + + expertIds, err := h.MediatorAssignment.AssignMediator(3, int(disputeId)) + if err != nil { + logger.WithError(err).Error("Error assigning experts to dispute") + c.JSON(http.StatusInternalServerError, models.Response{Error: "Error assigning experts to dispute"}) + return + } + + err = h.Model.AssignExpertswithDisputeAndExpertIDs(disputeId, expertIds) + if err != nil { + logger.WithError(err).Error("Error assigning experts to dispute") + c.JSON(http.StatusInternalServerError, models.Response{Error: "Error assigning experts to dispute"}) + return + } + } + logger.Info("Expert objections reviewed successfully") h.AuditLogger.LogDisputeProceedings(models.Disputes, map[string]interface{}{"user": claims, "message": "Expert objections reviewed successfully"}) diff --git a/api/handlers/dispute/dispute_test.go b/api/handlers/dispute/dispute_test.go index c55f0d3e..54b2b09f 100644 --- a/api/handlers/dispute/dispute_test.go +++ b/api/handlers/dispute/dispute_test.go @@ -2,6 +2,7 @@ package dispute_test import ( "api/handlers/dispute" + mediatorassignment "api/mediatorAssignment" "api/models" "bytes" "encoding/json" @@ -56,6 +57,7 @@ type DisputeErrorTestSuite struct { mockOrchestrator *mockOrchestrator mockEnv *mockEnv mockTicket *mockTicketModel + mockAlgorithm *mockAlgorithmModel } func (suite *DisputeErrorTestSuite) SetupTest() { @@ -66,8 +68,9 @@ func (suite *DisputeErrorTestSuite) SetupTest() { suite.mockOrchestrator = &mockOrchestrator{} suite.mockEnv = &mockEnv{} suite.mockTicket = &mockTicketModel{} + suite.mockAlgorithm = &mockAlgorithmModel{} - handler := dispute.Dispute{Model: suite.disputeMock, JWT: suite.jwtMock, Email: suite.emailMock, AuditLogger: suite.auditMock, OrchestratorEntity: suite.mockOrchestrator, Env: suite.mockEnv, TicketModel: suite.mockTicket} + handler := dispute.Dispute{Model: suite.disputeMock, JWT: suite.jwtMock, Email: suite.emailMock, AuditLogger: suite.auditMock, OrchestratorEntity: suite.mockOrchestrator, Env: suite.mockEnv, TicketModel: suite.mockTicket, MediatorAssignment:suite.mockAlgorithm} gin.SetMode("release") router := gin.Default() router.Use(suite.jwtMock.JWTMiddleware) @@ -101,6 +104,32 @@ func createFileField(w *multipart.Writer, field, filename, value string) { } // ---------------------------------------------------------------- MODEL MOCKS +//mock algorithgm model + +type mockAlgorithmModel struct { + throwErrors bool + Error error + returnValue float64 +} + +func (m *mockAlgorithmModel) CalculateScore(summaries []models.ExpertSummaryView, componentID int) []mediatorassignment.ResultWithID { + if m.throwErrors { + return nil + } + return []mediatorassignment.ResultWithID{ + { + ID: 1, + Result: m.returnValue, + }, + } +} + +func (m *mockAlgorithmModel) AssignMediator(count, disputeID int) ([]int, error) { + if m.throwErrors { + return nil, m.Error + } + return []int{1}, nil +} //ticket mock @@ -245,6 +274,21 @@ func (m *mockDisputeModel) GetEvidenceByDispute(disputeId int64) ([]models.Evide } return []models.Evidence{}, nil } + +func (m *mockDisputeModel) GetDisputeIDByTicketID(ticketID int64) (int64, error) { + if m.throwErrors { + return 0, errors.ErrUnsupported + } + return 0, nil +} + +func (m *mockDisputeModel) AssignExpertswithDisputeAndExpertIDs(disputeID int64, expertIDs []int) error { + if m.throwErrors { + return errors.ErrUnsupported + } + return nil +} + func (m *mockDisputeModel) GetDisputeExperts(disputeId int64) ([]models.Expert, error) { if m.throwErrors { return nil, errors.ErrUnsupported diff --git a/api/handlers/dispute/model.go b/api/handlers/dispute/model.go index f2bb0b66..a0fcb8fc 100644 --- a/api/handlers/dispute/model.go +++ b/api/handlers/dispute/model.go @@ -7,6 +7,7 @@ import ( "api/handlers/notifications" "api/handlers/ticket" "api/handlers/workflow" + mediatorassignment "api/mediatorAssignment" "api/middleware" "api/models" "api/utilities" @@ -46,10 +47,12 @@ type DisputeModel interface { ObjectExpert(disputeId, expertId, ticketId int64) error ReviewExpertObjection(objectionId int64, approved models.ExpObjStatus) error + GetDisputeIDByTicketID(ticketID int64) (int64, error) GetExpertRejections(expertID, disputeID *int64, limit, offset *int) ([]models.ExpertObjectionsView, error) CreateDefaultUser(email string, fullName string, pass string) error AssignExpertsToDispute(disputeID int64) ([]models.User, error) + AssignExpertswithDisputeAndExpertIDs(disputeID int64, expertIDs []int) error GetWorkflowRecordByID(id uint64) (*models.Workflow, error) CreateActiverWorkflow(workflow *models.ActiveWorkflows) error @@ -70,6 +73,7 @@ type Dispute struct { Env env.Env AuditLogger auditLogger.DisputeProceedingsLoggerInterface OrchestratorEntity WorkflowOrchestrator + MediatorAssignment mediatorassignment.AlgorithmAssignment WorkflowModel workflow.WorkflowDBModel } @@ -141,6 +145,7 @@ type disputeModelReal struct { } func NewHandler(db *gorm.DB, envReader env.Env) Dispute { + return Dispute{ Email: notifications.NewHandler(db), JWT: middleware.NewJwtMiddleware(), @@ -148,6 +153,7 @@ func NewHandler(db *gorm.DB, envReader env.Env) Dispute { Model: &disputeModelReal{db: db, env: env.NewEnvLoader()}, AuditLogger: auditLogger.NewDisputeProceedingsLogger(db, envReader), OrchestratorEntity: OrchestratorReal{}, + MediatorAssignment: mediatorassignment.DefaultAlorithmAssignment(db), TicketModel: ticket.NetTicketModelReal(db, envReader), WorkflowModel: &workflow.WorkflowModelReal{DB: db}, } @@ -444,6 +450,7 @@ func (m *disputeModelReal) GetDisputeExperts(disputeId int64) (experts []models. Select("users.id, users.first_name || ' ' || users.surname AS full_name, email, users.phone_number AS phone, role"). Joins("JOIN users ON dispute_experts_view.expert = users.id"). Where("dispute = ?", disputeId). + Where("dispute_experts_view.status = 'Approved'"). Where("role = 'Mediator' OR role = 'Arbitrator' OR role = 'Conciliator' OR role = 'expert'"). Find(&experts).Error @@ -624,6 +631,17 @@ func (m *disputeModelReal) CreateDefaultUser(email string, fullName string, pass // bandaid fix, will be removed in future +func (m *disputeModelReal) AssignExpertswithDisputeAndExpertIDs(disputeID int64, expertIDs []int) error { + logger := utilities.NewLogger().LogWithCaller() + for _, expertID := range expertIDs { + if err := m.db.Exec("INSERT INTO dispute_experts_view VALUES (?, ?)", disputeID, expertID).Error; err != nil { + logger.WithError(err).Error("Error inserting expert into dispute_experts table") + return err + } + } + return nil +} + func (m disputeModelReal) AssignExpertsToDispute(disputeID int64) ([]models.User, error) { // Seed the random number generator rand.Seed(time.Now().UnixNano()) @@ -983,3 +1001,22 @@ func (m *disputeModelReal) GetExpertRejections(expertID, disputeID *int64, limit return rejections, err } + +func (m *disputeModelReal) GetDisputeIDByTicketID(ticketID int64) (int64, error) { + logger := utilities.NewLogger().LogWithCaller() + var disputeID int64 + err := m.db.Raw(`SELECT + t.dispute_id +FROM + expert_objections eo +JOIN + tickets t ON eo.ticket_id = t.id +WHERE + eo.id = ?`, ticketID).Scan(&disputeID).Error + if err != nil { + logger.WithError(err).Error("Error retrieving dispute ID by ticket ID") + return 0 , err + } + return disputeID, err +} + diff --git a/api/mediatorAssignment/algorithm.go b/api/mediatorAssignment/algorithm.go new file mode 100644 index 00000000..3009985d --- /dev/null +++ b/api/mediatorAssignment/algorithm.go @@ -0,0 +1,147 @@ +package mediatorassignment + +import ( + "api/models" + "api/utilities" + "math/rand/v2" + "sort" + + "gorm.io/gorm" +) + +// MediatorAssignment struct and interface +type AlgorithmAssignment interface { + AssignMediator(count, disputeID int) ([]int, error) + CalculateScore(summaries []models.ExpertSummaryView, componentID int) []ResultWithID +} + +type MediatorAssignment struct { + Components []AlgorithmComponent + DB DBModel +} + +func (m *MediatorAssignment) AssignMediator(count, disputeID int) ([]int, error) { + logger := utilities.NewLogger().LogWithCaller() + // Get all the experts + experts, err := m.DB.GetExpertSummaryViews() + if err != nil { + return nil, err + } + + logger.Info("Experts\n", experts) + + // Loop through all the experts + var intermediateResults []ResultWithID + if len(experts) > 0 { + logger.Info("Calculating scores for experts") + intermediateResults = m.CalculateScore(experts, 0) + for i := 1; i < len(m.Components); i++ { + logger.Info("results\n", intermediateResults) + intermediateResults = m.Components[i].ApplyOperator(intermediateResults, m.CalculateScore(experts, i)) + } + } else { + intermediateResults = m.assignRandomValues(experts) + } + logger.Info("final\n", intermediateResults) + + + // Sort the results + sort.Slice(intermediateResults, func(i, j int) bool { + return intermediateResults[i].Result > intermediateResults[j].Result + }) + + logger.Info("Sorted results\n", intermediateResults) + //get top 10 experts and check they are not rejected + + + + topResults, err := m.GetTopResults(intermediateResults, count, disputeID) + if err != nil { + return nil, err + } + + logger.Info("Top results\n", topResults) + + // Get the expert IDs + var expertIDs []int + for _, result := range topResults { + expertIDs = append(expertIDs, int(result.ID)) + } + + logger.Info("Expert IDs\n", expertIDs) + return expertIDs, nil +} + +func (m *MediatorAssignment) CalculateScore(summaries []models.ExpertSummaryView, componentID int) []ResultWithID { + results := make([]ResultWithID, len(summaries)) + component := m.Components[componentID] + for i, summary := range summaries { + results[i] = component.CalculateScore(summary) + } + return results +} + +func (m *MediatorAssignment) assignRandomValues(summaries []models.ExpertSummaryView) []ResultWithID { + // assign random values to the experts + results := make([]ResultWithID, len(summaries)) + for i, summary := range summaries { + results[i] = ResultWithID{ID: summary.ExpertID, Result: rand.Float64()} + } + return results +} + +func (m *MediatorAssignment) GetTopResults(results []ResultWithID, count int, disputeID int) ([]ResultWithID,error) { + rejectedExperts, err := m.DB.GetRejectionFromDispute(disputeID) + if err != nil { + return nil, err + } + + var topResults []ResultWithID + index := 0 + for len(topResults) < count && index < len(results) { + if !m.isExpertRejected(rejectedExperts, results[index].ID) { + topResults = append(topResults, results[index]) + } + index++ + } + + return topResults, nil +} + +func (m *MediatorAssignment) isExpertRejected(rejectedExperts []models.DisputeExpert, expertID uint) bool { + for _, rejectedExpert := range rejectedExperts { + if rejectedExpert.Expert == int64(expertID) { + return true + } + } + return false +} + +func DefaultAlorithmAssignment(db *gorm.DB) *MediatorAssignment { + dbmodel := &DBModelReal{DB: db} + + return &MediatorAssignment{ + Components: []AlgorithmComponent{ + &BaseComponent{ + ScoreModeler: &LastAssignmentstruct{}, + Function: &Linear{BaseFunction: BaseFunction{MoveYAxis: 0, MoveXAxis: 0, ApplyCapToValue: true, Cap: 10,}, Multiplier: 1}, + Operator: &AddOperator{}, + }, + &BaseComponent{ + ScoreModeler: &AssignedDisputes{}, + Function: &Logarithmic{BaseFunction: BaseFunction{MoveYAxis: 0, MoveXAxis: 0, ApplyCapToValue: true, Cap: 10,}, LogBase: 10}, + Operator: &AddOperator{}, + }, + &BaseComponent{ + ScoreModeler: &RejectionCount{}, + Function: &Expontential{BaseFunction: BaseFunction{MoveYAxis: 0, MoveXAxis: 0, ApplyCapToValue: true, Cap: 10,}, BaseExponent: 10}, + Operator: &AddOperator{}, + }, + }, + DB: dbmodel, + } +} + +func (m *MediatorAssignment) AddComponent(component AlgorithmComponent) { + m.Components = append(m.Components, component) +} diff --git a/api/mediatorAssignment/component.go b/api/mediatorAssignment/component.go new file mode 100644 index 00000000..1cc1426d --- /dev/null +++ b/api/mediatorAssignment/component.go @@ -0,0 +1,39 @@ +package mediatorassignment + +import "api/models" + +// AglorithmComponent struct and interface + +type AlgorithmComponent interface { + CalculateScore(summary models.ExpertSummaryView) ResultWithID + ApplyOperator(value1 []ResultWithID, value2 []ResultWithID) []ResultWithID +} + +type BaseComponent struct { + ScoreModeler ScoreModeler + Function MathFunctions + Operator ComponentOperator +} + +func (b *BaseComponent) CalculateScore(summary models.ExpertSummaryView) ResultWithID { + result := b.ScoreModeler.GetScoreInput(summary) + result.Result = b.Function.CalculateScore(result.Result) + return result +} + +func (b *BaseComponent) ApplyOperator(value1 []ResultWithID, value2 []ResultWithID) []ResultWithID { + var results []ResultWithID + for i := 0; i < len(value1); i++ { + results = append(results, ResultWithID{ID: value1[i].ID, Result: b.Operator.ApplyOperator(value1[i].Result, value2[i].Result)}) + } + return results +} + + +func NewAlgorithmComponent(scoreModeler ScoreModeler, function MathFunctions, operator ComponentOperator) AlgorithmComponent { + return &BaseComponent{ + ScoreModeler: scoreModeler, + Function: function, + Operator: operator, + } +} diff --git a/api/mediatorAssignment/componentOperators.go b/api/mediatorAssignment/componentOperators.go new file mode 100644 index 00000000..46e0c9d5 --- /dev/null +++ b/api/mediatorAssignment/componentOperators.go @@ -0,0 +1,34 @@ +package mediatorassignment + + +type ComponentOperator interface { + ApplyOperator(value1 float64, value2 float64) float64 +} + +type AddOperator struct { +} + +func (a *AddOperator) ApplyOperator(value1 float64, value2 float64) float64 { + return value1 + value2 +} + +type SubtractOperator struct { +} + +func (s *SubtractOperator) ApplyOperator(value1 float64, value2 float64) float64 { + return value1 - value2 +} + +type MultiplyOperator struct { +} + +func (m *MultiplyOperator) ApplyOperator(value1 float64, value2 float64) float64 { + return value1 * value2 +} + +type DivideOperator struct { +} + +func (d *DivideOperator) ApplyOperator(value1 float64, value2 float64) float64 { + return value1 / value2 +} \ No newline at end of file diff --git a/api/mediatorAssignment/componentOperators_test.go b/api/mediatorAssignment/componentOperators_test.go new file mode 100644 index 00000000..5172a9c4 --- /dev/null +++ b/api/mediatorAssignment/componentOperators_test.go @@ -0,0 +1,45 @@ +package mediatorassignment_test + +import ( + "math" + "testing" + + mediatorassignment "api/mediatorAssignment" + + "github.com/stretchr/testify/assert" +) + +func TestAddOperator(t *testing.T) { + addOperator := &mediatorassignment.AddOperator{} + result := addOperator.ApplyOperator(3, 4) + expected := 7.0 + assert.Equal(t, expected, result) +} + +func TestSubtractOperator(t *testing.T) { + subtractOperator := &mediatorassignment.SubtractOperator{} + result := subtractOperator.ApplyOperator(10, 4) + expected := 6.0 + assert.Equal(t, expected, result) +} + +func TestMultiplyOperator(t *testing.T) { + multiplyOperator := &mediatorassignment.MultiplyOperator{} + result := multiplyOperator.ApplyOperator(3, 4) + expected := 12.0 + assert.Equal(t, expected, result) +} + +func TestDivideOperator(t *testing.T) { + divideOperator := &mediatorassignment.DivideOperator{} + result := divideOperator.ApplyOperator(12, 4) + expected := 3.0 + assert.Equal(t, expected, result) +} + +func TestDivideOperator_DivideByZero(t *testing.T) { + divideOperator := &mediatorassignment.DivideOperator{} + result := divideOperator.ApplyOperator(12, 0) + expected := float64(math.Inf(1)) + assert.Equal(t, expected, result) +} diff --git a/api/mediatorAssignment/component_test.go b/api/mediatorAssignment/component_test.go new file mode 100644 index 00000000..791469cd --- /dev/null +++ b/api/mediatorAssignment/component_test.go @@ -0,0 +1 @@ +package mediatorassignment_test \ No newline at end of file diff --git a/api/mediatorAssignment/dbScore.go b/api/mediatorAssignment/dbScore.go new file mode 100644 index 00000000..5f344b0a --- /dev/null +++ b/api/mediatorAssignment/dbScore.go @@ -0,0 +1,33 @@ +package mediatorassignment + +import ( + "api/models" + "time" +) + +type ScoreModeler interface { + GetScoreInput(summary models.ExpertSummaryView) ResultWithID +} + +type LastAssignmentstruct struct { +} + +func (d *LastAssignmentstruct) GetScoreInput(summary models.ExpertSummaryView) ResultWithID { + lastAssignment := time.Since(summary.LastAssignedDate).Hours() / 24 + score := &ResultWithID{ID: summary.ExpertID, Result: lastAssignment} + return *score +} + +type AssignedDisputes struct { +} + +func (d *AssignedDisputes) GetScoreInput(summary models.ExpertSummaryView) ResultWithID { + return ResultWithID{ID: summary.ExpertID, Result: float64(summary.ActiveDisputeCount)} +} + +type RejectionCount struct { +} + +func (d *RejectionCount) GetScoreInput(summary models.ExpertSummaryView) ResultWithID { + return ResultWithID{ID: summary.ExpertID, Result: float64(summary.RejectionPercentage)} +} diff --git a/api/mediatorAssignment/dbScore_test.go b/api/mediatorAssignment/dbScore_test.go new file mode 100644 index 00000000..79d210f7 --- /dev/null +++ b/api/mediatorAssignment/dbScore_test.go @@ -0,0 +1,129 @@ +package mediatorassignment_test + +import ( + "testing" + "time" + + mediatorassignment "api/mediatorAssignment" + "api/models" + + "github.com/stretchr/testify/assert" +) + +func TestLastAssignmentstruct_GetScoreInput(t *testing.T) { + tests := []struct { + name string + summary models.ExpertSummaryView + expectedResult mediatorassignment.ResultWithID + }{ + { + name: "Recent assignment", + summary: models.ExpertSummaryView{ + ExpertID: 1, + LastAssignedDate: time.Now().AddDate(0, 0, -1), // 1 day ago + }, + expectedResult: mediatorassignment.ResultWithID{ + ID: 1, + Result: 1, + }, + }, + { + name: "Old assignment", + summary: models.ExpertSummaryView{ + ExpertID: 2, + LastAssignedDate: time.Now().AddDate(0, -1, 0), // 1 month ago + }, + expectedResult: mediatorassignment.ResultWithID{ + ID: 2, + Result: 31, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &mediatorassignment.LastAssignmentstruct{} + result := d.GetScoreInput(tt.summary) + assert.InDelta(t, tt.expectedResult.Result, result.Result, 0.1, "Expected: %v, Actual: %v", tt.expectedResult.Result, result.Result) + assert.Equal(t, tt.expectedResult.ID, result.ID) + }) + } +} + +func TestAssignedDisputes_GetScoreInput(t *testing.T) { + tests := []struct { + name string + summary models.ExpertSummaryView + expectedResult mediatorassignment.ResultWithID + }{ + { + name: "No active disputes", + summary: models.ExpertSummaryView{ + ExpertID: 1, + ActiveDisputeCount: 0, + }, + expectedResult: mediatorassignment.ResultWithID{ + ID: 1, + Result: 0, + }, + }, + { + name: "Some active disputes", + summary: models.ExpertSummaryView{ + ExpertID: 2, + ActiveDisputeCount: 5, + }, + expectedResult: mediatorassignment.ResultWithID{ + ID: 2, + Result: 5, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &mediatorassignment.AssignedDisputes{} + result := d.GetScoreInput(tt.summary) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestRejectionCount_GetScoreInput(t *testing.T) { + tests := []struct { + name string + summary models.ExpertSummaryView + expectedResult mediatorassignment.ResultWithID + }{ + { + name: "No rejections", + summary: models.ExpertSummaryView{ + ExpertID: 1, + RejectionPercentage: 0, + }, + expectedResult: mediatorassignment.ResultWithID{ + ID: 1, + Result: 0, + }, + }, + { + name: "Some rejections", + summary: models.ExpertSummaryView{ + ExpertID: 2, + RejectionPercentage: 25, + }, + expectedResult: mediatorassignment.ResultWithID{ + ID: 2, + Result: 25, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &mediatorassignment.RejectionCount{} + result := d.GetScoreInput(tt.summary) + assert.Equal(t, tt.expectedResult, result) + }) + } +} diff --git a/api/mediatorAssignment/mathFunctions.go b/api/mediatorAssignment/mathFunctions.go new file mode 100644 index 00000000..09d100c6 --- /dev/null +++ b/api/mediatorAssignment/mathFunctions.go @@ -0,0 +1,66 @@ +package mediatorassignment + +import "math" + +type MathFunctions interface { + //function to calculate the score + CalculateScore(inputValue float64) float64 +} + +type BaseFunction struct { + ApplyCapToValue bool + Cap float64 + MoveYAxis float64 + MoveXAxis float64 +} + +type Expontential struct { + BaseExponent float64 + BaseFunction +} + +func (e *Expontential) CalculateScore(inputValue float64) float64 { + score := e.MoveYAxis + math.Pow(e.BaseExponent, inputValue) + e.MoveXAxis + + if e.ApplyCapToValue { + if score > e.Cap { + return e.Cap + } + } + + return score +} + +type Logarithmic struct { + BaseFunction + LogBase float64 +} + +func (l *Logarithmic) CalculateScore(inputValue float64) float64 { + score := l.MoveYAxis + math.Log(inputValue+l.MoveXAxis)/math.Log(l.LogBase) + + if l.ApplyCapToValue { + if score > l.Cap { + return l.Cap + } + } + + return score +} + +type Linear struct { + BaseFunction + Multiplier float64 +} + +func (l *Linear) CalculateScore(inputValue float64) float64 { + score := l.MoveYAxis + inputValue*l.Multiplier + l.MoveXAxis + + if l.ApplyCapToValue { + if score > l.Cap { + return l.Cap + } + } + + return score +} \ No newline at end of file diff --git a/api/mediatorAssignment/mathFunctions_test.go b/api/mediatorAssignment/mathFunctions_test.go new file mode 100644 index 00000000..42c6c3ab --- /dev/null +++ b/api/mediatorAssignment/mathFunctions_test.go @@ -0,0 +1,142 @@ +package mediatorassignment_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "api/mediatorAssignment" +) + +func TestExponentialCalculateScore(t *testing.T) { + tests := []struct { + name string + exponential mediatorassignment.Expontential + inputValue float64 + expectedResult float64 + }{ + { + name: "No cap applied", + exponential: mediatorassignment.Expontential{ + BaseExponent: 2, + BaseFunction: mediatorassignment.BaseFunction{ + ApplyCapToValue: false, + MoveYAxis: 1, + MoveXAxis: 1, + }, + }, + inputValue: 3, + expectedResult: 10, + }, + { + name: "Cap applied", + exponential: mediatorassignment.Expontential{ + BaseExponent: 2, + BaseFunction: mediatorassignment.BaseFunction{ + ApplyCapToValue: true, + Cap: 5, + MoveYAxis: 1, + MoveXAxis: 1, + }, + }, + inputValue: 3, + expectedResult: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.exponential.CalculateScore(tt.inputValue) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestLogarithmicCalculateScore(t *testing.T) { + tests := []struct { + name string + logarithmic mediatorassignment.Logarithmic + inputValue float64 + expectedResult float64 + }{ + { + name: "No cap applied", + logarithmic: mediatorassignment.Logarithmic{ + LogBase: 2, + BaseFunction: mediatorassignment.BaseFunction{ + ApplyCapToValue: false, + MoveYAxis: 1, + MoveXAxis: 1, + }, + }, + inputValue: 8, + expectedResult: 4.169925, // Adjusted for precision + }, + { + name: "Cap applied", + logarithmic: mediatorassignment.Logarithmic{ + LogBase: 2, + BaseFunction: mediatorassignment.BaseFunction{ + ApplyCapToValue: true, + Cap: 3, + MoveYAxis: 1, + MoveXAxis: 1, + }, + }, + inputValue: 8, + expectedResult: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.logarithmic.CalculateScore(tt.inputValue) + // Use a small delta for floating-point comparison (tolerance of 0.00001) + assert.InDelta(t, tt.expectedResult, result, 0.00001, "Expected: %v, Actual: %v", tt.expectedResult, result) + }) + } +} + + +func TestLinearCalculateScore(t *testing.T) { + tests := []struct { + name string + linear mediatorassignment.Linear + inputValue float64 + expectedResult float64 + }{ + { + name: "No cap applied", + linear: mediatorassignment.Linear{ + Multiplier: 2, + BaseFunction: mediatorassignment.BaseFunction{ + ApplyCapToValue: false, + MoveYAxis: 1, + MoveXAxis: 1, + }, + }, + inputValue: 3, + expectedResult: 8, + }, + { + name: "Cap applied", + linear: mediatorassignment.Linear{ + Multiplier: 2, + BaseFunction: mediatorassignment.BaseFunction{ + ApplyCapToValue: true, + Cap: 5, + MoveYAxis: 1, + MoveXAxis: 1, + }, + }, + inputValue: 3, + expectedResult: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.linear.CalculateScore(tt.inputValue) + assert.Equal(t, tt.expectedResult, result) + }) + } +} \ No newline at end of file diff --git a/api/mediatorAssignment/model.go b/api/mediatorAssignment/model.go new file mode 100644 index 00000000..6943eca3 --- /dev/null +++ b/api/mediatorAssignment/model.go @@ -0,0 +1,56 @@ +package mediatorassignment + +import ( + "api/models" + + "gorm.io/gorm" +) + +type ResultWithID struct { + ID uint + Result float64 +} + + +const ( + ExpertIDColumn = "expert_id" + ExpertNameColumn = "expert_name" + RejectionPercentageColumn = "rejection_percentage" + LastAssignedDateColumn = "last_assigned_date" + AssignedDisputeCountColumn = "assigned_dispute_count" +) + +type DBModel interface { + GetExpertSummaryViews() ([]models.ExpertSummaryView, error) + GetExpertSummaryViewByExpertID(expertID int) (models.ExpertSummaryView, error) + GetExpertSummaryViewByColumnValue(columnName string, columnValue string) (models.ExpertSummaryView, error) + GetRejectionFromDispute(disputeId int) ([]models.DisputeExpert, error) +} + +type DBModelReal struct { + DB *gorm.DB +} + +func (d *DBModelReal) GetExpertSummaryViews() ([]models.ExpertSummaryView, error) { + var expertSummaryViews []models.ExpertSummaryView + d.DB.Find(&expertSummaryViews) + return expertSummaryViews, nil +} + +func (d *DBModelReal) GetExpertSummaryViewByExpertID(expertID int) (models.ExpertSummaryView, error) { + var expertSummaryView models.ExpertSummaryView + d.DB.Where(ExpertIDColumn, expertID).First(&expertSummaryView) + return expertSummaryView, nil +} + +func (d *DBModelReal) GetExpertSummaryViewByColumnValue(columnName string, columnValue string) (models.ExpertSummaryView, error) { + var expertSummaryView models.ExpertSummaryView + d.DB.Where(columnName, columnValue).First(&expertSummaryView) + return expertSummaryView, nil +} + +func (d *DBModelReal) GetRejectionFromDispute(disputeId int) ([]models.DisputeExpert, error) { + var disputeExpert []models.DisputeExpert + d.DB.Where("dispute = ?", disputeId).Find(&disputeExpert) + return disputeExpert, nil +} \ No newline at end of file diff --git a/api/models/models.go b/api/models/models.go index b27e0633..9430fa27 100644 --- a/api/models/models.go +++ b/api/models/models.go @@ -288,3 +288,16 @@ type ExpertObjectionsView struct { func (ExpertObjectionsView) TableName() string { return "expert_objections_view" } + +type ExpertSummaryView struct { + ExpertID uint `gorm:"column:expert_id; primaryKey" json:"expert_id"` + ExpertName string `gorm:"column:expert_name" json:"expert_name"` + RejectionPercentage float64 `gorm:"column:rejection_percentage" json:"rejection_percentage"` + LastAssignedDate time.Time `gorm:"column:last_assigned_date" json:"last_assigned_date"` + ActiveDisputeCount int `gorm:"column:active_dispute_count" json:"active_dispute_count"` +} + +// TableName specifies the table name for GORM +func (ExpertSummaryView) TableName() string { + return "expert_summary_view" +} diff --git a/initdb/init.sql b/initdb/init.sql index dc63b459..4dea1864 100644 --- a/initdb/init.sql +++ b/initdb/init.sql @@ -284,8 +284,95 @@ CREATE TABLE dispute_decisions ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Date the writeup was submitted UNIQUE (dispute_id) -- One decision per dispute, regardless of who submitted it ); +------------------------------------------------------------- View +-- Define the get_rejection_percentage_for_expert function first +CREATE OR REPLACE FUNCTION get_rejection_percentage_for_expert(expert_id_input BIGINT) +RETURNS NUMERIC AS $$ +DECLARE + total_assigned INT; + total_rejected INT; + rejection_percentage NUMERIC; +BEGIN + -- Get the total number of disputes assigned to the expert + SELECT COUNT(de.dispute) + INTO total_assigned + FROM dispute_experts de + WHERE de."user" = expert_id_input; + + -- Get the number of disputes where the expert was rejected + SELECT COUNT(de.dispute) + INTO total_rejected + FROM dispute_experts de + JOIN dispute_experts_view dev ON de.dispute = dev.dispute AND de."user" = dev.expert + WHERE dev.expert = expert_id_input AND dev.status = 'Rejected'; + + -- Calculate rejection percentage + IF total_assigned = 0 THEN + rejection_percentage := 0; -- If no disputes are assigned, return 0% to avoid division by zero + ELSE + rejection_percentage := (total_rejected::NUMERIC / total_assigned) * 100; + END IF; + + RETURN rejection_percentage; +END; +$$ LANGUAGE plpgsql; + +-- Define the get_last_assigned_date_for_expert function +CREATE OR REPLACE FUNCTION get_last_assigned_date_for_expert(expert_id_input BIGINT) +RETURNS DATE AS $$ +DECLARE + last_assigned_date DATE; +BEGIN + SELECT + MAX(d.date_resolved) -- Get the most recent date_resolved + INTO last_assigned_date + FROM + dispute_experts de + JOIN + disputes d ON de.dispute = d.id + WHERE + de."user" = expert_id_input AND -- Filter for the specific expert + d.date_resolved IS NOT NULL; -- Only consider disputes that have been resolved + + RETURN last_assigned_date; +END; +$$ LANGUAGE plpgsql; +-- Define the get_active_dispute_count_for_expert function +CREATE OR REPLACE FUNCTION get_active_dispute_count_for_expert(expert_id_input BIGINT) +RETURNS INT AS $$ +DECLARE + active_dispute_count INT; +BEGIN + SELECT + COUNT(d.id) + INTO active_dispute_count + FROM + dispute_experts de + JOIN + disputes d ON de.dispute = d.id + WHERE + de."user" = expert_id_input AND -- Filter for the specific expert + d.date_resolved IS NULL; -- Check if the dispute is still unresolved + + RETURN active_dispute_count; +END; +$$ LANGUAGE plpgsql; +-- Now define the view that uses these functions +CREATE OR REPLACE VIEW expert_summary_view AS +SELECT + u.id AS expert_id, + u.first_name || ' ' || u.surname AS expert_name, + get_rejection_percentage_for_expert(u.id::BIGINT) AS rejection_percentage, + get_last_assigned_date_for_expert(u.id::BIGINT) AS last_assigned_date, + get_active_dispute_count_for_expert(u.id::BIGINT) AS active_dispute_count +FROM + users u +JOIN + dispute_experts de ON u.id = de."user" +GROUP BY + u.id, u.first_name, u.surname; CREATE OR REPLACE VIEW expert_objections_view AS SELECT @@ -310,7 +397,6 @@ JOIN JOIN users "user" ON t.created_by = "user".id; - ------------------------------------------------------------- TABLE CONTENTS INSERT INTO Countries (country_code, country_name) VALUES ('AF', 'Afghanistan'),