diff --git a/pkg/models/re_contract_year.go b/pkg/models/re_contract_year.go index 42ad4be73bd..3caa91cb4d2 100644 --- a/pkg/models/re_contract_year.go +++ b/pkg/models/re_contract_year.go @@ -1,14 +1,37 @@ package models import ( + "fmt" "time" "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" "github.com/gofrs/uuid" + "github.com/transcom/mymove/pkg/apperror" ) +const ( + BasePeriodYear1 string = "Base Period Year 1" + BasePeriodYear2 string = "Base Period Year 2" + BasePeriodYear3 string = "Base Period Year 3" + OptionPeriod1 string = "Option Period 1" + OptionPeriod2 string = "Option Period 2" + OptionPeriod3 string = "Option Period 3" + AwardTerm1 string = "Award Term 1" + AwardTerm2 string = "Award Term 2" + AwardTerm string = "Award Term" + OptionPeriod string = "Option Period" + BasePeriodYear string = "Base Period Year" +) + +type ExpectedEscalationPriceContractsCount struct { + ExpectedAmountOfContractYearsForCalculation int + ExpectedAmountOfBasePeriodYearsForCalculation int + ExpectedAmountOfOptionPeriodYearsForCalculation int + ExpectedAmountOfAwardTermsForCalculation int +} + // ReContractYear represents a single "year" of a contract type ReContractYear struct { ID uuid.UUID `json:"id" db:"id"` @@ -46,3 +69,85 @@ func (r *ReContractYear) Validate(_ *pop.Connection) (*validate.Errors, error) { &Float64IsGreaterThan{Field: r.EscalationCompounded, Name: "EscalationCompounded", Compared: 0}, ), nil } + +func GetExpectedEscalationPriceContractsCount(contractYearName string, hasOptionYear3 bool) (ExpectedEscalationPriceContractsCount, error) { + switch contractYearName { + case BasePeriodYear1: + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 1, + ExpectedAmountOfBasePeriodYearsForCalculation: 1, + ExpectedAmountOfOptionPeriodYearsForCalculation: 0, + ExpectedAmountOfAwardTermsForCalculation: 0, + }, nil + case BasePeriodYear2: + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 2, + ExpectedAmountOfBasePeriodYearsForCalculation: 2, + ExpectedAmountOfOptionPeriodYearsForCalculation: 0, + ExpectedAmountOfAwardTermsForCalculation: 0, + }, nil + case BasePeriodYear3: + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 3, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 0, + ExpectedAmountOfAwardTermsForCalculation: 0, + }, nil + case OptionPeriod1: + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 4, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 1, + ExpectedAmountOfAwardTermsForCalculation: 0, + }, nil + case OptionPeriod2: + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 5, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 2, + ExpectedAmountOfAwardTermsForCalculation: 0, + }, nil + case OptionPeriod3: + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 6, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 3, + ExpectedAmountOfAwardTermsForCalculation: 0, + }, nil + case AwardTerm1: + if hasOptionYear3 { + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 7, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 3, + ExpectedAmountOfAwardTermsForCalculation: 1, + }, nil + } else { + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 6, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 2, + ExpectedAmountOfAwardTermsForCalculation: 1, + }, nil + } + case AwardTerm2: + if hasOptionYear3 { + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 8, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 3, + ExpectedAmountOfAwardTermsForCalculation: 2, + }, nil + } else { + return ExpectedEscalationPriceContractsCount{ + ExpectedAmountOfContractYearsForCalculation: 7, + ExpectedAmountOfBasePeriodYearsForCalculation: 3, + ExpectedAmountOfOptionPeriodYearsForCalculation: 2, + ExpectedAmountOfAwardTermsForCalculation: 2, + }, nil + } + default: + err := apperror.NewInternalServerError(fmt.Sprintf("Unexpected contract year name %s.", contractYearName)) + return ExpectedEscalationPriceContractsCount{}, err + } +} diff --git a/pkg/services/ghcrateengine/domestic_origin_pricer_test.go b/pkg/services/ghcrateengine/domestic_origin_pricer_test.go index 4ffeaae60e8..0e44c66f312 100644 --- a/pkg/services/ghcrateengine/domestic_origin_pricer_test.go +++ b/pkg/services/ghcrateengine/domestic_origin_pricer_test.go @@ -69,7 +69,7 @@ func (suite *GHCRateEngineServiceSuite) TestPriceDomesticOriginWithServiceItemPa suite.Equal(expectedCost, cost) expectedParams := services.PricingDisplayParams{ - {Key: models.ServiceItemParamNameContractYearName, Value: "Test Contract Year"}, + {Key: models.ServiceItemParamNameContractYearName, Value: "Base Period Year 1"}, {Key: models.ServiceItemParamNameEscalationCompounded, Value: "1.04070"}, {Key: models.ServiceItemParamNameIsPeak, Value: "true"}, {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: "1.46"}, @@ -126,7 +126,7 @@ func (suite *GHCRateEngineServiceSuite) TestPriceDomesticOrigin() { suite.Equal(expectedCost, cost) expectedParams := services.PricingDisplayParams{ - {Key: models.ServiceItemParamNameContractYearName, Value: "Test Contract Year"}, + {Key: models.ServiceItemParamNameContractYearName, Value: "Base Period Year 1"}, {Key: models.ServiceItemParamNameEscalationCompounded, Value: "1.04070"}, {Key: models.ServiceItemParamNameIsPeak, Value: "true"}, {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: "1.46"}, @@ -153,7 +153,7 @@ func (suite *GHCRateEngineServiceSuite) TestPriceDomesticOrigin() { suite.Equal(expectedCost, cost) expectedParams := services.PricingDisplayParams{ - {Key: models.ServiceItemParamNameContractYearName, Value: "Test Contract Year"}, + {Key: models.ServiceItemParamNameContractYearName, Value: "Base Period Year 1"}, {Key: models.ServiceItemParamNameEscalationCompounded, Value: "1.04070"}, {Key: models.ServiceItemParamNameIsPeak, Value: "false"}, {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: "1.27"}, @@ -286,7 +286,7 @@ func (suite *GHCRateEngineServiceSuite) TestPriceDomesticOrigin() { suite.Equal(basePriceCents/5, fifthPriceCents) expectedParams := services.PricingDisplayParams{ - {Key: models.ServiceItemParamNameContractYearName, Value: "Test Contract Year"}, + {Key: models.ServiceItemParamNameContractYearName, Value: "Base Period Year 1"}, {Key: models.ServiceItemParamNameEscalationCompounded, Value: "1.04070"}, {Key: models.ServiceItemParamNameIsPeak, Value: "true"}, {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: "1.46"}, diff --git a/pkg/services/ghcrateengine/pricer_helpers.go b/pkg/services/ghcrateengine/pricer_helpers.go index 450d8e7fdc6..05ba943fa40 100644 --- a/pkg/services/ghcrateengine/pricer_helpers.go +++ b/pkg/services/ghcrateengine/pricer_helpers.go @@ -3,6 +3,7 @@ package ghcrateengine import ( "fmt" "math" + "strconv" "time" "github.com/gofrs/uuid" @@ -493,14 +494,101 @@ func escalatePriceForContractYear(appCtx appcontext.AppContext, contractID uuid. } escalatedPrice = roundToPrecision(escalatedPrice, precision) - escalatedPrice = escalatedPrice * contractYear.EscalationCompounded + + if contractYear.Name == models.BasePeriodYear1 { + escalatedPrice = escalatedPrice * contractYear.EscalationCompounded + } else { + escalatedPrice, err = compoundEscalationFactors(appCtx, contractID, contractYear, escalatedPrice) + return 0, contractYear, err + } + escalatedPrice = roundToPrecision(escalatedPrice, precision) return escalatedPrice, contractYear, nil } +func compoundEscalationFactors(appCtx appcontext.AppContext, contractID uuid.UUID, contractYear models.ReContractYear, escalatedPrice float64) (float64, error) { + // Get all contracts based on contract Id + contractYearsFromDB, err := fetchContractsByContractId(appCtx, contractID) + if err != nil { + return escalatedPrice, fmt.Errorf("could not lookup contracts by Id: %w", err) + } + + // A contract may have Option Year 3 but it is not guaranteed. Need to know if it does or not + contractsYearsFromDBMap := make(map[string]models.ReContractYear) + hasOptionYear3 := false + for _, contract := range contractYearsFromDB { + if contract.Name == models.OptionPeriod3 { + hasOptionYear3 = true + } + // Add re_contract_years record to map + contractsYearsFromDBMap[contract.Name] = contract + } + + // Get expectations for price escalations calculations + expectations, err := models.GetExpectedEscalationPriceContractsCount(contractYear.Name, hasOptionYear3) + if err != nil { + err := apperror.NewInternalServerError(fmt.Sprintf("Error getting expectations for escalated price calculations", err)) + return escalatedPrice, err + } + + // Adding contracts that are expected to be in the calculations based on the contract year to a map + contractYearsForCalculation := make(map[string]models.ReContractYear) + if expectations.ExpectedAmountOfAwardTermsForCalculation > 0 { + contractYearsForCalculation, err = addContractsForEscalationCalculation(contractYearsForCalculation, contractsYearsFromDBMap, expectations.ExpectedAmountOfAwardTermsForCalculation, models.AwardTerm) + if err != nil { + err := apperror.NewInternalServerError(fmt.Sprintf("Error collecting expected Award Term contracts for escalated price calculations", err)) + return escalatedPrice, err + } + } + if expectations.ExpectedAmountOfOptionPeriodYearsForCalculation > 0 { + contractYearsForCalculation, err = addContractsForEscalationCalculation(contractYearsForCalculation, contractsYearsFromDBMap, expectations.ExpectedAmountOfOptionPeriodYearsForCalculation, models.OptionPeriod) + if err != nil { + err := apperror.NewInternalServerError(fmt.Sprintf("Error collecting expected Option Period contracts for escalated price calculations", err)) + return escalatedPrice, err + } + } + if expectations.ExpectedAmountOfBasePeriodYearsForCalculation > 0 { + contractYearsForCalculation, err = addContractsForEscalationCalculation(contractYearsForCalculation, contractsYearsFromDBMap, expectations.ExpectedAmountOfBasePeriodYearsForCalculation, models.BasePeriodYear) + if err != nil { + err := apperror.NewInternalServerError(fmt.Sprintf("Error collecting expected Base Period Year contracts for escalated price calculations", err)) + return escalatedPrice, err + } + } + + // Make sure the expected amount of contracts are being used in the escalated Price calculation + if len(contractYearsForCalculation) != expectations.ExpectedAmountOfContractYearsForCalculation { + err := apperror.NewInternalServerError("Unexpected amount of contract years being used in escalated price calculation") + return escalatedPrice, err + } + + // Multiply the escalated price by each re_contract_years record escalation factor. EscalatedPrice = EscalatedPrice * ContractEscalationFactor + var compoundedEscalatedPrice = escalatedPrice + for _, contract := range contractYearsForCalculation { + compoundedEscalatedPrice = compoundedEscalatedPrice * contract.Escalation + } + + return escalatedPrice, nil +} + // roundToPrecision rounds a float64 value to the number of decimal points indicated by the precision. // TODO: Future cleanup could involve moving this function to a math/utility package with some simple tests func roundToPrecision(value float64, precision int) float64 { ratio := math.Pow(10, float64(precision)) return math.Round(value*ratio) / ratio } + +func addContractsForEscalationCalculation(contractsMap map[string]models.ReContractYear, contractsMapDB map[string]models.ReContractYear, contractsAmount int, contractName string) (map[string]models.ReContractYear, error) { + if contractsAmount > 0 { + for i := contractsAmount; i != 0; i-- { + name := fmt.Sprintf("%s %s", contractName, strconv.FormatInt(int64(i), 10)) + // If a contract that is expected to be used in the calculations is not found then return error + if _, exist := contractsMapDB[name]; exist { + contractsMap[contractsMapDB[name].Name] = contractsMapDB[name] + } else { + err := apperror.NewInternalServerError(fmt.Sprintf("Expected %s contract not found", name)) + return contractsMap, err + } + } + } + return contractsMap, nil +} diff --git a/pkg/services/ghcrateengine/pricer_query_helpers.go b/pkg/services/ghcrateengine/pricer_query_helpers.go index e2b6f91810a..5fc88dd7d5e 100644 --- a/pkg/services/ghcrateengine/pricer_query_helpers.go +++ b/pkg/services/ghcrateengine/pricer_query_helpers.go @@ -93,6 +93,16 @@ func fetchContractYear(appCtx appcontext.AppContext, contractID uuid.UUID, date return contractYear, nil } +func fetchContractsByContractId(appCtx appcontext.AppContext, contractID uuid.UUID) (models.ReContractYears, error) { + var contracts models.ReContractYears + err := appCtx.DB().Where("contract_id = $1", contractID).All(&contracts) + if err != nil { + return models.ReContractYears{}, err + } + + return contracts, nil +} + func fetchShipmentTypePrice(appCtx appcontext.AppContext, contractCode string, serviceCode models.ReServiceCode, market models.Market) (models.ReShipmentTypePrice, error) { var shipmentTypePrice models.ReShipmentTypePrice err := appCtx.DB().Q(). diff --git a/pkg/services/ghcrateengine/pricer_query_helpers_test.go b/pkg/services/ghcrateengine/pricer_query_helpers_test.go index 87396cc0278..349f0bdeae4 100644 --- a/pkg/services/ghcrateengine/pricer_query_helpers_test.go +++ b/pkg/services/ghcrateengine/pricer_query_helpers_test.go @@ -52,7 +52,7 @@ func (suite *GHCRateEngineServiceSuite) Test_fetchDomServiceAreaPrice() { testCents := unit.Cents(353) suite.Run("golden path", func() { - suite.setupDomesticServiceAreaPrice(models.ReServiceCodeDOFSIT, testServiceArea, testIsPeakPeriod, testCents, "Test Contract Year", 1.125) + suite.setupDomesticServiceAreaPrice(models.ReServiceCodeDOFSIT, testServiceArea, testIsPeakPeriod, testCents, "Base Period Year 1", 1.125) domServiceAreaPrice, err := fetchDomServiceAreaPrice(suite.AppContextForTest(), testdatagen.DefaultContractCode, models.ReServiceCodeDOFSIT, testServiceArea, testIsPeakPeriod) suite.NoError(err) @@ -60,7 +60,7 @@ func (suite *GHCRateEngineServiceSuite) Test_fetchDomServiceAreaPrice() { }) suite.Run("no records found", func() { - suite.setupDomesticServiceAreaPrice(models.ReServiceCodeDOFSIT, testServiceArea, testIsPeakPeriod, testCents, "Test Contract Year", 1.125) + suite.setupDomesticServiceAreaPrice(models.ReServiceCodeDOFSIT, testServiceArea, testIsPeakPeriod, testCents, "Base Period Year 1", 1.125) // Look for service code DDFSIT that we haven't added _, err := fetchDomServiceAreaPrice(suite.AppContextForTest(), testdatagen.DefaultContractCode, models.ReServiceCodeDDFSIT, testServiceArea, testIsPeakPeriod) diff --git a/pkg/testdatagen/make_re_contract_year.go b/pkg/testdatagen/make_re_contract_year.go index 2f9c2c2e378..e8a74cf332f 100644 --- a/pkg/testdatagen/make_re_contract_year.go +++ b/pkg/testdatagen/make_re_contract_year.go @@ -20,7 +20,7 @@ func MakeReContractYear(db *pop.Connection, assertions Assertions) models.ReCont reContractYear := models.ReContractYear{ ContractID: reContract.ID, - Name: "Test Contract Year", + Name: "Base Period Year 1", StartDate: time.Date(TestYear, time.January, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(TestYear, time.December, 31, 0, 0, 0, 0, time.UTC), Escalation: 1.0197,