diff --git a/migrations/app/migrations_manifest.txt b/migrations/app/migrations_manifest.txt index 89110e709e4..a044a514cad 100644 --- a/migrations/app/migrations_manifest.txt +++ b/migrations/app/migrations_manifest.txt @@ -1091,6 +1091,7 @@ 20250121153007_update_pricing_proc_to_handle_international_shuttle.up.sql 20250121184450_upd_duty_loc_B-22242.up.sql 20250123173216_add_destination_queue_db_func_and_gbloc_view.up.sql +20250123210535_update_re_intl_transit_times_for_ak_hhg.up.sql 20250204162411_updating_create_accessorial_service_item_proc_for_crating.up.sql 20250206173204_add_hawaii_data.up.sql 20250207153450_add_fetch_documents_func.up.sql diff --git a/migrations/app/schema/20250123210535_update_re_intl_transit_times_for_ak_hhg.up.sql b/migrations/app/schema/20250123210535_update_re_intl_transit_times_for_ak_hhg.up.sql new file mode 100644 index 00000000000..fb67d5fee8b --- /dev/null +++ b/migrations/app/schema/20250123210535_update_re_intl_transit_times_for_ak_hhg.up.sql @@ -0,0 +1,9 @@ +UPDATE re_intl_transit_times + SET hhg_transit_time = 10 +WHERE origin_rate_area_id IN ('b80a00d4-f829-4051-961a-b8945c62c37d','5a27e806-21d4-4672-aa5e-29518f10c0aa') + OR destination_rate_area_id IN ('b80a00d4-f829-4051-961a-b8945c62c37d','5a27e806-21d4-4672-aa5e-29518f10c0aa'); + +update re_intl_transit_times + SET hhg_transit_time = 20 +WHERE origin_rate_area_id IN ('9bb87311-1b29-4f29-8561-8a4c795654d4','635e4b79-342c-4cfc-8069-39c408a2decd') + OR destination_rate_area_id IN ('9bb87311-1b29-4f29-8561-8a4c795654d4','635e4b79-342c-4cfc-8069-39c408a2decd'); \ No newline at end of file diff --git a/pkg/factory/address_factory.go b/pkg/factory/address_factory.go index 27d92999d00..ad4ce46507f 100644 --- a/pkg/factory/address_factory.go +++ b/pkg/factory/address_factory.go @@ -201,3 +201,75 @@ func GetTraitAddress4() []Customization { }, } } + +// GetTraitAddressAKZone1 is an address in Zone 1 of AK +func GetTraitAddressAKZone1() []Customization { + + return []Customization{ + { + Model: models.Address{ + StreetAddress1: "82 Joe Gibbs Rd", + StreetAddress2: models.StringPointer("P.O. Box 1234"), + StreetAddress3: models.StringPointer("c/o Another Person"), + City: "ANCHORAGE", + State: "AK", + PostalCode: "99695", + IsOconus: models.BoolPointer(true), + }, + }, + } +} + +// GetTraitAddressAKZone2 is an address in Zone 2 of Alaska +func GetTraitAddressAKZone2() []Customization { + + return []Customization{ + { + Model: models.Address{ + StreetAddress1: "44 John Riggins Rd", + StreetAddress2: models.StringPointer("P.O. Box 1234"), + StreetAddress3: models.StringPointer("c/o Another Person"), + City: "FAIRBANKS", + State: "AK", + PostalCode: "99703", + IsOconus: models.BoolPointer(true), + }, + }, + } +} + +// GetTraitAddressAKZone3 is an address in Zone 3 of Alaska +func GetTraitAddressAKZone3() []Customization { + + return []Customization{ + { + Model: models.Address{ + StreetAddress1: "26 Clinton Portis Rd", + StreetAddress2: models.StringPointer("P.O. Box 1234"), + StreetAddress3: models.StringPointer("c/o Another Person"), + City: "KODIAK", + State: "AK", + PostalCode: "99697", + IsOconus: models.BoolPointer(true), + }, + }, + } +} + +// GetTraitAddressAKZone4 is an address in Zone 4 of Alaska +func GetTraitAddressAKZone4() []Customization { + + return []Customization{ + { + Model: models.Address{ + StreetAddress1: "8 Alex Ovechkin Rd", + StreetAddress2: models.StringPointer("P.O. Box 1234"), + StreetAddress3: models.StringPointer("c/o Another Person"), + City: "JUNEAU", + State: "AK", + PostalCode: "99801", + IsOconus: models.BoolPointer(true), + }, + }, + } +} diff --git a/pkg/models/address.go b/pkg/models/address.go index 29a6bedbcbb..b4ed2723750 100644 --- a/pkg/models/address.go +++ b/pkg/models/address.go @@ -9,6 +9,7 @@ import ( "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" "github.com/gofrs/uuid" + "github.com/pkg/errors" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -146,6 +147,13 @@ func (a *Address) LineDisplayFormat() string { return fmt.Sprintf("%s%s%s, %s, %s %s", a.StreetAddress1, optionalStreetAddress2, optionalStreetAddress3, a.City, a.State, a.PostalCode) } +func (a *Address) IsAddressAlaska() (bool, error) { + if a == nil { + return false, errors.New("address is nil") + } + return a.State == "AK", nil +} + // NotImplementedCountryCode is the default for unimplemented country code lookup type NotImplementedCountryCode struct { message string diff --git a/pkg/models/address_test.go b/pkg/models/address_test.go index 9dfe5a7fa1c..c7ef3c1053b 100644 --- a/pkg/models/address_test.go +++ b/pkg/models/address_test.go @@ -385,3 +385,34 @@ func (suite *ModelSuite) Test_FetchDutyLocationGblocForAK() { suite.Equal(string(*gbloc), "MAPK") }) } + +func (suite *ModelSuite) TestIsAddressAlaska() { + var address *m.Address + bool1, err := address.IsAddressAlaska() + suite.Error(err) + suite.Equal("address is nil", err.Error()) + suite.Equal(false, bool1) + + address = &m.Address{ + StreetAddress1: "street 1", + StreetAddress2: m.StringPointer("street 2"), + StreetAddress3: m.StringPointer("street 3"), + City: "city", + PostalCode: "90210", + County: m.StringPointer("County"), + } + + bool2, err := address.IsAddressAlaska() + suite.NoError(err) + suite.Equal(m.BoolPointer(false), &bool2) + + address.State = "MT" + bool3, err := address.IsAddressAlaska() + suite.NoError(err) + suite.Equal(m.BoolPointer(false), &bool3) + + address.State = "AK" + bool4, err := address.IsAddressAlaska() + suite.NoError(err) + suite.Equal(m.BoolPointer(true), &bool4) +} diff --git a/pkg/services/mto_shipment/mto_shipment_updater.go b/pkg/services/mto_shipment/mto_shipment_updater.go index fb75c795a77..7788c05d731 100644 --- a/pkg/services/mto_shipment/mto_shipment_updater.go +++ b/pkg/services/mto_shipment/mto_shipment_updater.go @@ -1075,7 +1075,7 @@ func (o *mtoShipmentStatusUpdater) setRequiredDeliveryDate(appCtx appcontext.App pickupLocation = shipment.PickupAddress deliveryLocation = shipment.DestinationAddress } - requiredDeliveryDate, calcErr := CalculateRequiredDeliveryDate(appCtx, o.planner, *pickupLocation, *deliveryLocation, *shipment.ScheduledPickupDate, weight.Int(), shipment.MarketCode) + requiredDeliveryDate, calcErr := CalculateRequiredDeliveryDate(appCtx, o.planner, *pickupLocation, *deliveryLocation, *shipment.ScheduledPickupDate, weight.Int(), shipment.MarketCode, shipment.MoveTaskOrderID) if calcErr != nil { return calcErr } @@ -1192,18 +1192,7 @@ func reServiceCodesForShipment(shipment models.MTOShipment) []models.ReServiceCo // CalculateRequiredDeliveryDate function is used to get a distance calculation using the pickup and destination addresses. It then uses // the value returned to make a fetch on the ghc_domestic_transit_times table and returns a required delivery date // based on the max_days_transit_time. -func CalculateRequiredDeliveryDate(appCtx appcontext.AppContext, planner route.Planner, pickupAddress models.Address, destinationAddress models.Address, pickupDate time.Time, weight int, marketCode models.MarketCode) (*time.Time, error) { - // Okay, so this is something to get us able to take care of the 20 day condition over in the gdoc linked in this - // story: https://dp3.atlassian.net/browse/MB-1141 - // We unfortunately didn't get a lot of guidance regarding vicinity. So for now we're taking zip codes that are the - // explicitly mentioned 20 day cities and those in the same county (that I've manually compiled together here). - // If a move is in that group it adds 20 days, if it's not in that group, but is in Alaska it adds 10 days. - // Else it will not do either of those things. - // The cities for 20 days are: Adak, Kodiak, Juneau, Ketchikan, and Sitka. As well as others in their 'vicinity.' - twentyDayAKZips := [28]string{"99546", "99547", "99591", "99638", "99660", "99685", "99692", "99550", "99608", - "99615", "99619", "99624", "99643", "99644", "99697", "99650", "99801", "99802", "99803", "99811", "99812", - "99950", "99824", "99850", "99901", "99928", "99950", "99835"} - +func CalculateRequiredDeliveryDate(appCtx appcontext.AppContext, planner route.Planner, pickupAddress models.Address, destinationAddress models.Address, pickupDate time.Time, weight int, marketCode models.MarketCode, moveID uuid.UUID) (*time.Time, error) { internationalShipment := marketCode == models.MarketCodeInternational distance, err := planner.ZipTransitDistance(appCtx, pickupAddress.PostalCode, destinationAddress.PostalCode, internationalShipment) @@ -1225,17 +1214,59 @@ func CalculateRequiredDeliveryDate(appCtx appcontext.AppContext, planner route.P // Add the max transit time to the pickup date to get the new required delivery date requiredDeliveryDate := pickupDate.AddDate(0, 0, ghcDomesticTransitTime.MaxDaysTransitTime) - // Let's add some days if we're dealing with an alaska shipment. - if destinationAddress.State == "AK" { - for _, zip := range twentyDayAKZips { - if destinationAddress.PostalCode == zip { - // Add an extra 10 days here, so that after we add the 10 for being in AK we wind up with a total of 20 - requiredDeliveryDate = requiredDeliveryDate.AddDate(0, 0, 10) - break + destinationIsAlaska, err := destinationAddress.IsAddressAlaska() + if err != nil { + return nil, fmt.Errorf("destination address is nil for move ID: %s", moveID) + } + pickupIsAlaska, err := pickupAddress.IsAddressAlaska() + if err != nil { + return nil, fmt.Errorf("pickup address is nil for move ID: %s", moveID) + } + // Let's add some days if we're dealing with a shipment between CONUS/Alaska + if (destinationIsAlaska || pickupIsAlaska) && !(destinationIsAlaska && pickupIsAlaska) { + var rateAreaID uuid.UUID + var intlTransTime models.InternationalTransitTime + + contract, err := models.FetchContractForMove(appCtx, moveID) + if err != nil { + return nil, fmt.Errorf("error fetching contract for move ID: %s", moveID) + } + + if destinationIsAlaska { + rateAreaID, err = models.FetchRateAreaID(appCtx.DB(), destinationAddress.ID, &uuid.Nil, contract.ID) + if err != nil { + return nil, fmt.Errorf("error fetching destination rate area id for address ID: %s", destinationAddress.ID) + } + err = appCtx.DB().Where("destination_rate_area_id = $1", rateAreaID).First(&intlTransTime) + if err != nil { + switch err { + case sql.ErrNoRows: + return nil, fmt.Errorf("no international transit time found for destination rate area ID: %s", rateAreaID) + default: + return nil, err + } + } + } + + if pickupIsAlaska { + rateAreaID, err = models.FetchRateAreaID(appCtx.DB(), pickupAddress.ID, &uuid.Nil, contract.ID) + if err != nil { + return nil, fmt.Errorf("error fetching pickup rate area id for address ID: %s", pickupAddress.ID) + } + err = appCtx.DB().Where("origin_rate_area_id = $1", rateAreaID).First(&intlTransTime) + if err != nil { + switch err { + case sql.ErrNoRows: + return nil, fmt.Errorf("no international transit time found for pickup rate area ID: %s", rateAreaID) + default: + return nil, err + } } } - // Add an extra 10 days for being in AK - requiredDeliveryDate = requiredDeliveryDate.AddDate(0, 0, 10) + + if intlTransTime.HhgTransitTime != nil { + requiredDeliveryDate = requiredDeliveryDate.AddDate(0, 0, *intlTransTime.HhgTransitTime) + } } // return the value diff --git a/pkg/services/mto_shipment/mto_shipment_updater_test.go b/pkg/services/mto_shipment/mto_shipment_updater_test.go index 5cdd5100b13..e408c246cd0 100644 --- a/pkg/services/mto_shipment/mto_shipment_updater_test.go +++ b/pkg/services/mto_shipment/mto_shipment_updater_test.go @@ -2462,6 +2462,137 @@ func (suite *MTOShipmentServiceSuite) TestUpdateMTOShipmentStatus() { } }) + suite.Run("Test that we are properly adding days to Alaska shipments", func() { + reContract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + Contract: reContract, + ContractID: reContract.ID, + StartDate: time.Now(), + EndDate: time.Now().Add(time.Hour * 12), + Escalation: 1.0, + EscalationCompounded: 1.0, + }, + }) + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + appCtx := suite.AppContextForTest() + + ghcDomesticTransitTime0LbsUpper := models.GHCDomesticTransitTime{ + MaxDaysTransitTime: 12, + WeightLbsLower: 10001, + WeightLbsUpper: 0, + DistanceMilesLower: 0, + DistanceMilesUpper: 10000, + } + verrs, err := suite.DB().ValidateAndCreate(&ghcDomesticTransitTime0LbsUpper) + suite.Assert().False(verrs.HasAny()) + suite.NoError(err) + + conusAddress := factory.BuildAddress(suite.DB(), nil, []factory.Trait{factory.GetTraitAddress2}) + zone1Address := factory.BuildAddress(suite.DB(), nil, []factory.Trait{factory.GetTraitAddressAKZone1}) + zone2Address := factory.BuildAddress(suite.DB(), nil, []factory.Trait{factory.GetTraitAddressAKZone2}) + zone3Address := factory.BuildAddress(suite.DB(), nil, []factory.Trait{factory.GetTraitAddressAKZone3}) + zone4Address := factory.BuildAddress(suite.DB(), nil, []factory.Trait{factory.GetTraitAddressAKZone4}) + + estimatedWeight := unit.Pound(11000) + + testCases10Days := []struct { + pickupLocation models.Address + destinationLocation models.Address + }{ + {conusAddress, zone1Address}, + {conusAddress, zone2Address}, + {zone1Address, conusAddress}, + {zone2Address, conusAddress}, + } + // adding 22 days; ghcDomesticTransitTime0LbsUpper.MaxDaysTransitTime is 12, plus 10 for Zones 1 and 2 + rdd10DaysDate := testdatagen.DateInsidePeakRateCycle.AddDate(0, 0, 22) + for _, testCase := range testCases10Days { + shipment := factory.BuildMTOShipmentMinimal(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + { + Model: models.MTOShipment{ + ShipmentType: models.MTOShipmentTypeHHG, + ScheduledPickupDate: &testdatagen.DateInsidePeakRateCycle, + PrimeEstimatedWeight: &estimatedWeight, + Status: models.MTOShipmentStatusSubmitted, + }, + }, + { + Model: testCase.pickupLocation, + Type: &factory.Addresses.PickupAddress, + LinkOnly: true, + }, + { + Model: testCase.destinationLocation, + Type: &factory.Addresses.DeliveryAddress, + LinkOnly: true, + }, + }, nil) + shipmentEtag := etag.GenerateEtag(shipment.UpdatedAt) + _, err = updater.UpdateMTOShipmentStatus(appCtx, shipment.ID, status, nil, nil, shipmentEtag) + suite.NoError(err) + + fetchedShipment := models.MTOShipment{} + err = suite.DB().Find(&fetchedShipment, shipment.ID) + suite.NoError(err) + suite.NotNil(fetchedShipment.RequiredDeliveryDate) + suite.Equal(rdd10DaysDate.Format(time.RFC3339), fetchedShipment.RequiredDeliveryDate.Format(time.RFC3339)) + } + + testCases20Days := []struct { + pickupLocation models.Address + destinationLocation models.Address + }{ + {conusAddress, zone3Address}, + {conusAddress, zone4Address}, + {zone3Address, conusAddress}, + {zone4Address, conusAddress}, + } + // adding 32 days; ghcDomesticTransitTime0LbsUpper.MaxDaysTransitTime is 12, plus 20 for Zones 3 and 4 + rdd20DaysDate := testdatagen.DateInsidePeakRateCycle.AddDate(0, 0, 32) + for _, testCase := range testCases20Days { + shipment := factory.BuildMTOShipmentMinimal(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + { + Model: models.MTOShipment{ + ShipmentType: models.MTOShipmentTypeHHG, + ScheduledPickupDate: &testdatagen.DateInsidePeakRateCycle, + PrimeEstimatedWeight: &estimatedWeight, + Status: models.MTOShipmentStatusSubmitted, + }, + }, + { + Model: testCase.pickupLocation, + Type: &factory.Addresses.PickupAddress, + LinkOnly: true, + }, + { + Model: testCase.destinationLocation, + Type: &factory.Addresses.DeliveryAddress, + LinkOnly: true, + }, + }, nil) + shipmentEtag := etag.GenerateEtag(shipment.UpdatedAt) + _, err = updater.UpdateMTOShipmentStatus(appCtx, shipment.ID, status, nil, nil, shipmentEtag) + suite.NoError(err) + + fetchedShipment := models.MTOShipment{} + err = suite.DB().Find(&fetchedShipment, shipment.ID) + suite.NoError(err) + suite.NotNil(fetchedShipment.RequiredDeliveryDate) + fmt.Println("fetchedShipment.RequiredDeliveryDate") + fmt.Println(fetchedShipment.RequiredDeliveryDate) + suite.Equal(rdd20DaysDate.Format(time.RFC3339), fetchedShipment.RequiredDeliveryDate.Format(time.RFC3339)) + } + }) + suite.Run("Cannot set SUBMITTED status on shipment via UpdateMTOShipmentStatus", func() { setupTestData() diff --git a/pkg/services/mto_shipment/rules.go b/pkg/services/mto_shipment/rules.go index 0fe7e481ebc..604da6a12f0 100644 --- a/pkg/services/mto_shipment/rules.go +++ b/pkg/services/mto_shipment/rules.go @@ -343,7 +343,7 @@ func checkPrimeValidationsOnModel(planner route.Planner) validator { weight = older.NTSRecordedWeight } requiredDeliveryDate, err := CalculateRequiredDeliveryDate(appCtx, planner, *latestPickupAddress, - *latestDestinationAddress, *latestSchedPickupDate, weight.Int(), older.MarketCode) + *latestDestinationAddress, *latestSchedPickupDate, weight.Int(), older.MarketCode, older.MoveTaskOrderID) if err != nil { verrs.Add("requiredDeliveryDate", err.Error()) } diff --git a/pkg/services/mto_shipment/shipment_approver.go b/pkg/services/mto_shipment/shipment_approver.go index 9191657787c..fcce3db616b 100644 --- a/pkg/services/mto_shipment/shipment_approver.go +++ b/pkg/services/mto_shipment/shipment_approver.go @@ -247,7 +247,7 @@ func (f *shipmentApprover) setRequiredDeliveryDate(appCtx appcontext.AppContext, deliveryLocation = shipment.DestinationAddress weight = shipment.PrimeEstimatedWeight.Int() } - requiredDeliveryDate, calcErr := CalculateRequiredDeliveryDate(appCtx, f.planner, *pickupLocation, *deliveryLocation, *shipment.ScheduledPickupDate, weight, shipment.MarketCode) + requiredDeliveryDate, calcErr := CalculateRequiredDeliveryDate(appCtx, f.planner, *pickupLocation, *deliveryLocation, *shipment.ScheduledPickupDate, weight, shipment.MarketCode, shipment.MoveTaskOrderID) if calcErr != nil { return calcErr }