From 183e17993f487dc3ca6d68e6729bfd0a33ea368a Mon Sep 17 00:00:00 2001 From: Michael Inthavongsay Date: Mon, 24 Feb 2025 16:11:36 +0000 Subject: [PATCH 1/5] initial checkin --- migrations/app/ddl_functions_manifest.txt | 1 + ...3413_fn_update_service_item_pricing.up.sql | 150 +++++++++ pkg/services/ghc_rate_engine.go | 28 ++ ...l_destination_sit_fuel_surcharge_pricer.go | 108 +++++++ ...tination_sit_fuel_surcharge_pricer_test.go | 298 ++++++++++++++++++ ...tional_origin_sit_fuel_surcharge_pricer.go | 109 +++++++ ...l_origin_sit_fuel_surcharge_pricer_test.go | 298 ++++++++++++++++++ .../ghcrateengine/service_item_pricer.go | 4 + .../shipment_address_update_requester.go | 6 +- 9 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql create mode 100644 pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go create mode 100644 pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer_test.go create mode 100644 pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go create mode 100644 pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer_test.go diff --git a/migrations/app/ddl_functions_manifest.txt b/migrations/app/ddl_functions_manifest.txt index 237796e829e..63d284a65ad 100644 --- a/migrations/app/ddl_functions_manifest.txt +++ b/migrations/app/ddl_functions_manifest.txt @@ -1,3 +1,4 @@ # This is the functions(procedures) migrations manifest. # If a migration is not recorded here, then it will error. # Naming convention: fn_some_function.up.sql running will create this file. +20250221213413_fn_update_service_item_pricing.up.sql diff --git a/migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql b/migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql new file mode 100644 index 00000000000..98cec1041ba --- /dev/null +++ b/migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql @@ -0,0 +1,150 @@ +--B-22462 M.Inthavongsay Adding initial migration file for update_service_item_pricing stored procedure using new migration process. +--Also updating to allow IOSFSC and IDSFSC SIT service items. + +CREATE OR REPLACE PROCEDURE update_service_item_pricing( + shipment_id UUID, + mileage INT +) AS +' +DECLARE + shipment RECORD; + service_item RECORD; + escalated_price NUMERIC; + estimated_price NUMERIC; + o_rate_area_id UUID; + d_rate_area_id UUID; + contract_id UUID; + service_code TEXT; + o_zip_code TEXT; + d_zip_code TEXT; + distance NUMERIC; + estimated_fsc_multiplier NUMERIC; + fuel_price NUMERIC; + cents_above_baseline NUMERIC; + price_difference NUMERIC; +BEGIN + SELECT ms.id, ms.pickup_address_id, ms.destination_address_id, ms.requested_pickup_date, ms.prime_estimated_weight + INTO shipment + FROM mto_shipments ms + WHERE ms.id = shipment_id; + + IF shipment IS NULL THEN + RAISE EXCEPTION ''Shipment with ID % not found'', shipment_id; + END IF; + + -- exit the proc if prime_estimated_weight is NULL + IF shipment.prime_estimated_weight IS NULL THEN + RETURN; + END IF; + + -- loop through service items in the shipment + FOR service_item IN + SELECT si.id, si.re_service_id + FROM mto_service_items si + WHERE si.mto_shipment_id = shipment_id + LOOP + -- get the service code for the current service item to determine calculation + SELECT code + INTO service_code + FROM re_services + WHERE id = service_item.re_service_id; + + CASE + WHEN service_code IN (''ISLH'', ''UBP'') THEN + contract_id := get_contract_id(shipment.requested_pickup_date); + o_rate_area_id := get_rate_area_id(shipment.pickup_address_id, service_item.re_service_id, contract_id); + d_rate_area_id := get_rate_area_id(shipment.destination_address_id, service_item.re_service_id, contract_id); + escalated_price := calculate_escalated_price(o_rate_area_id, d_rate_area_id, service_item.re_service_id, contract_id, service_code, shipment.requested_pickup_date); + + IF shipment.prime_estimated_weight IS NOT NULL THEN + -- multiply by 110% of estimated weight + estimated_price := ROUND((escalated_price * (shipment.prime_estimated_weight * 1.1) / 100), 2) * 100; + RAISE NOTICE ''%: Received estimated price of % (% * (% * 1.1) / 100)) cents'', service_code, estimated_price, escalated_price, shipment.prime_estimated_weight; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; + END IF; + + WHEN service_code IN (''IHPK'', ''IUBPK'', ''IOSHUT'') THEN + -- perform IHPK/IUBPK-specific logic (no destination rate area) + contract_id := get_contract_id(shipment.requested_pickup_date); + o_rate_area_id := get_rate_area_id(shipment.pickup_address_id, service_item.re_service_id, contract_id); + escalated_price := calculate_escalated_price(o_rate_area_id, NULL, service_item.re_service_id, contract_id, service_code, shipment.requested_pickup_date); + + IF shipment.prime_estimated_weight IS NOT NULL THEN + -- multiply by 110% of estimated weight + estimated_price := ROUND((escalated_price * (shipment.prime_estimated_weight * 1.1) / 100), 2) * 100; + RAISE NOTICE ''%: Received estimated price of % (% * (% * 1.1) / 100)) cents'', service_code, estimated_price, escalated_price, shipment.prime_estimated_weight; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; + END IF; + + WHEN service_code IN (''IHUPK'', ''IUBUPK'', ''IDSHUT'') THEN + -- perform IHUPK/IUBUPK-specific logic (no origin rate area) + contract_id := get_contract_id(shipment.requested_pickup_date); + d_rate_area_id := get_rate_area_id(shipment.destination_address_id, service_item.re_service_id, contract_id); + escalated_price := calculate_escalated_price(NULL, d_rate_area_id, service_item.re_service_id, contract_id, service_code, shipment.requested_pickup_date); + + IF shipment.prime_estimated_weight IS NOT NULL THEN + -- multiply by 110% of estimated weight + estimated_price := ROUND((escalated_price * (shipment.prime_estimated_weight * 1.1) / 100), 2) * 100; + RAISE NOTICE ''%: Received estimated price of % (% * (% * 1.1) / 100)) cents'', service_code, estimated_price, escalated_price, shipment.prime_estimated_weight; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; + END IF; + + WHEN service_code IN (''POEFSC'', ''PODFSC'') THEN + distance = mileage; + + -- getting FSC multiplier from re_fsc_multipliers + estimated_fsc_multiplier := get_fsc_multiplier(shipment.prime_estimated_weight); + + fuel_price := get_fuel_price(shipment.requested_pickup_date); + + price_difference := calculate_price_difference(fuel_price); + + IF estimated_fsc_multiplier IS NOT NULL AND distance IS NOT NULL THEN + cents_above_baseline := distance * estimated_fsc_multiplier; + RAISE NOTICE ''Distance: % * FSC Multipler: % = $% cents above baseline of $2.50'', distance, estimated_fsc_multiplier, cents_above_baseline; + RAISE NOTICE ''The fuel price is % above the baseline (% - 250000 baseline)'', price_difference, fuel_price; + estimated_price := ROUND((cents_above_baseline * price_difference) * 100); + RAISE NOTICE ''Received estimated price of % cents for service_code: %.'', estimated_price, service_code; + + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; + END IF; + + WHEN service_code IN (''IOSFSC'', ''IDSFSC'') THEN + distance = mileage; + + -- getting FSC multiplier from re_fsc_multipliers + estimated_fsc_multiplier := get_fsc_multiplier(shipment.prime_estimated_weight); + + fuel_price := get_fuel_price(shipment.requested_pickup_date); + + price_difference := calculate_price_difference(fuel_price); + + IF estimated_fsc_multiplier IS NOT NULL AND distance IS NOT NULL THEN + cents_above_baseline := distance * estimated_fsc_multiplier; + RAISE NOTICE ''Distance: % * FSC Multipler: % = $% cents above baseline of $2.50'', distance, estimated_fsc_multiplier, cents_above_baseline; + RAISE NOTICE ''The fuel price is % above the baseline (% - 250000 baseline)'', price_difference, fuel_price; + estimated_price := ROUND((cents_above_baseline * price_difference) * 100); + RAISE NOTICE ''Received estimated price of % cents for service_code: %.'', estimated_price, service_code; + + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; + END IF; + END CASE; + END LOOP; +END; +' +LANGUAGE plpgsql; \ No newline at end of file diff --git a/pkg/services/ghc_rate_engine.go b/pkg/services/ghc_rate_engine.go index 8944ad1b1bc..e40fce34c3d 100644 --- a/pkg/services/ghc_rate_engine.go +++ b/pkg/services/ghc_rate_engine.go @@ -229,6 +229,14 @@ type DomesticDestinationSITFuelSurchargePricer interface { ParamsPricer } +// InternationalDestinationSITFuelSurchargePricer prices international destination SIT fuel surcharge +// +//go:generate mockery --name InternationalDestinationSITFuelSurchargePricer +type InternationalDestinationSITFuelSurchargePricer interface { + Price(appCtx appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, PricingDisplayParams, error) + ParamsPricer +} + // DomesticOriginSITFuelSurchargePricer prices domestic origin SIT fuel surcharge // //go:generate mockery --name DomesticOriginSITFuelSurchargePricer @@ -249,6 +257,26 @@ type DomesticOriginSITFuelSurchargePricer interface { ParamsPricer } +// InternationalOriginSITFuelSurchargePricer prices international origin SIT fuel surcharge +// +//go:generate mockery --name InternationalOriginSITFuelSurchargePricer +type InternationalOriginSITFuelSurchargePricer interface { + Price( + appCtx appcontext.AppContext, + actualPickupDate time.Time, + distance unit.Miles, + weight unit.Pound, + fscWeightBasedDistanceMultiplier float64, + eiaFuelPrice unit.Millicents, + isPPM bool, + ) ( + unit.Cents, + PricingDisplayParams, + error, + ) + ParamsPricer +} + // IntlShippingAndLinehaulPricer prices international shipping and linehaul for a move // //go:generate mockery --name IntlShippingAndLinehaulPricer diff --git a/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go b/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go new file mode 100644 index 00000000000..313ce85cd89 --- /dev/null +++ b/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go @@ -0,0 +1,108 @@ +package ghcrateengine + +import ( + "database/sql" + "fmt" + "math" + "time" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/apperror" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/unit" +) + +type internationalDestinationSITFuelSurchargePricer struct { +} + +func NewInternationalDestinationSITFuelSurchargePricer() services.InternationalDestinationSITFuelSurchargePricer { + return &internationalDestinationSITFuelSurchargePricer{} +} + +func (p internationalDestinationSITFuelSurchargePricer) Price(_ appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { + // Validate parameters + if actualPickupDate.IsZero() { + return 0, nil, errors.New("ActualPickupDate is required") + } + if distance <= 0 { + return 0, nil, errors.New("Distance must be greater than 0") + } + if !isPPM && weight < minInternationalWeight { + return 0, nil, fmt.Errorf("Weight must be a minimum of %d", minInternationalWeight) + } + if fscWeightBasedDistanceMultiplier == 0 { + return 0, nil, errors.New("WeightBasedDistanceMultiplier is required") + } + if eiaFuelPrice == 0 { + return 0, nil, errors.New("EIAFuelPrice is required") + } + + fscPriceDifferenceInCents := (eiaFuelPrice - baseGHCDieselFuelPrice).Float64() / 1000.0 + fscMultiplier := fscWeightBasedDistanceMultiplier * distance.Float64() + fscPrice := fscMultiplier * fscPriceDifferenceInCents * 100 + totalCost := unit.Cents(math.Round(fscPrice)) + + displayParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameFSCPriceDifferenceInCents, Value: FormatFloat(fscPriceDifferenceInCents, 1)}, + {Key: models.ServiceItemParamNameFSCMultiplier, Value: FormatFloat(fscMultiplier, 7)}, + } + + return totalCost, displayParams, nil +} + +func (p internationalDestinationSITFuelSurchargePricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + actualPickupDate, err := getParamTime(params, models.ServiceItemParamNameActualPickupDate) + if err != nil { + return unit.Cents(0), nil, err + } + + var paymentServiceItem models.PaymentServiceItem + mtoShipment := params[0].PaymentServiceItem.MTOServiceItem.MTOShipment + + if mtoShipment.ID == uuid.Nil { + err = appCtx.DB().Eager("MTOServiceItem", "MTOServiceItem.MTOShipment").Find(&paymentServiceItem, params[0].PaymentServiceItemID) + if err != nil { + switch err { + case sql.ErrNoRows: + return unit.Cents(0), nil, apperror.NewNotFoundError(params[0].PaymentServiceItemID, "looking for PaymentServiceItem") + default: + return unit.Cents(0), nil, apperror.NewQueryError("PaymentServiceItem", err, "") + } + } + mtoShipment = paymentServiceItem.MTOServiceItem.MTOShipment + } + + distance, err := getParamInt(params, models.ServiceItemParamNameDistanceZipSITDest) + if err != nil { + return unit.Cents(0), nil, err + } + + weightBilled, err := getParamInt(params, models.ServiceItemParamNameWeightBilled) + if err != nil { + return unit.Cents(0), nil, err + } + + fscWeightBasedDistanceMultiplier, err := getParamFloat(params, models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier) + if err != nil { + return unit.Cents(0), nil, err + } + + eiaFuelPrice, err := getParamInt(params, models.ServiceItemParamNameEIAFuelPrice) + if err != nil { + return unit.Cents(0), nil, err + } + + var isPPM = false + if params[0].PaymentServiceItem.MTOServiceItem.MTOShipment.ShipmentType == models.MTOShipmentTypePPM { + // PPMs do not require minimums for a shipment's weight + // this flag is passed into the Price function to ensure the weight min + // are not enforced for PPMs + isPPM = true + } + + return p.Price(appCtx, actualPickupDate, unit.Miles(distance), unit.Pound(weightBilled), fscWeightBasedDistanceMultiplier, unit.Millicents(eiaFuelPrice), isPPM) +} diff --git a/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer_test.go b/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer_test.go new file mode 100644 index 00000000000..efeb915a362 --- /dev/null +++ b/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer_test.go @@ -0,0 +1,298 @@ +package ghcrateengine + +import ( + "fmt" + "time" + + "github.com/gofrs/uuid" + + "github.com/transcom/mymove/pkg/apperror" + "github.com/transcom/mymove/pkg/factory" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/testdatagen" + "github.com/transcom/mymove/pkg/unit" +) + +const ( + idsfscTestDistance = unit.Miles(2276) + idsfscTestWeight = unit.Pound(4025) + idsfscWeightDistanceMultiplier = float64(0.000417) + idsfscFuelPrice = unit.Millicents(281400) + idsfscPriceCents = unit.Cents(2980) +) + +var idsfscActualPickupDate = time.Date(testdatagen.TestYear, time.June, 5, 7, 33, 11, 456, time.UTC) + +func (suite *GHCRateEngineServiceSuite) TestPriceInternationalDestinationSITFuelSurcharge() { + pricer := NewInternationalDestinationSITFuelSurchargePricer() + + suite.Run("success without PaymentServiceItemParams", func() { + isPPM := false + priceCents, _, err := pricer.Price(suite.AppContextForTest(), idsfscActualPickupDate, idsfscTestDistance, idsfscTestWeight, idsfscWeightDistanceMultiplier, idsfscFuelPrice, isPPM) + suite.NoError(err) + suite.Equal(idsfscPriceCents, priceCents) + }) + + suite.Run("success without PaymentServiceItemParams when shipment is PPM with < 500 lb weight", func() { + isPPM := true + priceCents, _, err := pricer.Price(suite.AppContextForTest(), idsfscActualPickupDate, idsfscTestDistance, unit.Pound(250), idsfscWeightDistanceMultiplier, idsfscFuelPrice, isPPM) + suite.NoError(err) + suite.Equal(idsfscPriceCents, priceCents) + }) + + suite.Run("IDSFSC is negative if fuel price from EIA is below $2.50", func() { + isPPM := false + priceCents, _, err := pricer.Price(suite.AppContextForTest(), idsfscActualPickupDate, idsfscTestDistance, idsfscTestWeight, idsfscWeightDistanceMultiplier, 242400, isPPM) + suite.NoError(err) + suite.Equal(unit.Cents(-721), priceCents) + }) + + suite.Run("Price validation errors", func() { + type priceArgs struct { + actualPickupDate time.Time + distance unit.Miles + weight unit.Pound + fscWeightBasedDistanceMultiplier float64 + eiaFuelPrice unit.Millicents + isPPM bool + } + + testCases := map[string]struct { + priceArgs priceArgs + errorMessage string + }{ + "Missing ActualPickupDate": { + priceArgs: priceArgs{ + actualPickupDate: time.Time{}, + distance: idsfscTestDistance, + weight: idsfscTestWeight, + fscWeightBasedDistanceMultiplier: idsfscWeightDistanceMultiplier, + eiaFuelPrice: idsfscFuelPrice, + isPPM: false, + }, + errorMessage: "ActualPickupDate is required", + }, + "Below minimum weight": { + priceArgs: priceArgs{ + actualPickupDate: idsfscActualPickupDate, + distance: idsfscTestDistance, + weight: unit.Pound(0), + fscWeightBasedDistanceMultiplier: idsfscWeightDistanceMultiplier, + eiaFuelPrice: idsfscFuelPrice, + isPPM: false, + }, + errorMessage: fmt.Sprintf("Weight must be a minimum of %d", minInternationalWeight), + }, + "Missing FSCWeightBasedDistanceMultiplier": { + priceArgs: priceArgs{ + actualPickupDate: idsfscActualPickupDate, + distance: idsfscTestDistance, + weight: idsfscTestWeight, + fscWeightBasedDistanceMultiplier: 0, + eiaFuelPrice: idsfscFuelPrice, + isPPM: false, + }, + errorMessage: "WeightBasedDistanceMultiplier is required", + }, + "Missing EIAFuelPrice": { + priceArgs: priceArgs{ + actualPickupDate: idsfscActualPickupDate, + distance: idsfscTestDistance, + weight: idsfscTestWeight, + fscWeightBasedDistanceMultiplier: idsfscWeightDistanceMultiplier, + eiaFuelPrice: 0, + isPPM: false, + }, + errorMessage: "EIAFuelPrice is required", + }, + "Missing Distance": { + priceArgs: priceArgs{ + actualPickupDate: idsfscActualPickupDate, + distance: unit.Miles(0), + weight: idsfscTestWeight, + fscWeightBasedDistanceMultiplier: idsfscWeightDistanceMultiplier, + eiaFuelPrice: idsfscFuelPrice, + isPPM: false, + }, + errorMessage: "Distance must be greater than 0", + }, + } + + for name, testcase := range testCases { + suite.Run(name, func() { + _, _, err := pricer.Price(suite.AppContextForTest(), testcase.priceArgs.actualPickupDate, testcase.priceArgs.distance, testcase.priceArgs.weight, testcase.priceArgs.fscWeightBasedDistanceMultiplier, testcase.priceArgs.eiaFuelPrice, testcase.priceArgs.isPPM) + suite.Error(err) + suite.Equal(testcase.errorMessage, err.Error()) + }) + } + }) +} + +func (suite *GHCRateEngineServiceSuite) TestPriceUsingParamsInternationalDestinationSITFuelSurcharge() { + pricer := NewInternationalDestinationSITFuelSurchargePricer() + + fscPriceDifferenceInCents := (idsfscFuelPrice - baseGHCDieselFuelPrice).Float64() / 1000.0 + fscMultiplier := idsfscWeightDistanceMultiplier * idsfscTestDistance.Float64() + + setupTestData := func() models.PaymentServiceItem { + paymentServiceItem := factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIDSFSC, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameActualPickupDate, + KeyType: models.ServiceItemParamTypeDate, + Value: idsfscActualPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNameDistanceZipSITDest, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idsfscTestDistance)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idsfscTestWeight)), + }, + { + Key: models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier, + KeyType: models.ServiceItemParamTypeDecimal, + Value: fmt.Sprintf("%.7f", idsfscWeightDistanceMultiplier), // we need precision 7 to handle values like 0.0006255 + }, + { + Key: models.ServiceItemParamNameEIAFuelPrice, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idsfscFuelPrice)), + }, + }, nil, nil, + ) + + return paymentServiceItem + } + + suite.Run("success using PaymentServiceItemParams", func() { + paymentServiceItem := setupTestData() + priceCents, displayParams, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.NoError(err) + suite.Equal(idsfscPriceCents, priceCents) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameFSCPriceDifferenceInCents, Value: FormatFloat(fscPriceDifferenceInCents, 1)}, + {Key: models.ServiceItemParamNameFSCMultiplier, Value: FormatFloat(fscMultiplier, 7)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("PriceUsingParams validation errors", func() { + testCases := map[string]struct { + missingPaymentServiceItem models.ServiceItemParamName + errorMessage string + }{ + "Missing ActualPickupDate": { + missingPaymentServiceItem: models.ServiceItemParamNameActualPickupDate, + errorMessage: "could not find param with key ActualPickupDate", + }, + "Missing WeightBilled": { + missingPaymentServiceItem: models.ServiceItemParamNameWeightBilled, + errorMessage: "could not find param with key WeightBilled", + }, + "Missing FSCWeightBasedDistanceMultiplier": { + missingPaymentServiceItem: models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier, + errorMessage: "could not find param with key FSCWeightBasedDistanceMultiplier", + }, + "Missing EIAFuelPrice": { + missingPaymentServiceItem: models.ServiceItemParamNameEIAFuelPrice, + errorMessage: "could not find param with key EIAFuelPrice", + }, + "Missing Distance": { + missingPaymentServiceItem: models.ServiceItemParamNameDistanceZipSITDest, + errorMessage: "could not find param with key DistanceZipSITDest", + }, + } + + for name, testcase := range testCases { + suite.Run(name, func() { + paymentServiceItem := setupTestData() + params := suite.removeOnePaymentServiceItem(paymentServiceItem.PaymentServiceItemParams, testcase.missingPaymentServiceItem) + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), params) + suite.Error(err) + suite.Equal(testcase.errorMessage, err.Error()) + }) + } + }) + + suite.Run("not found error on PaymentServiceItem", func() { + paymentServiceItem := setupTestData() + paramsWithBadReference := paymentServiceItem.PaymentServiceItemParams + paramsWithBadReference[0].PaymentServiceItemID = uuid.Nil + // Pricer only searches for the shipment when the ID is nil + paramsWithBadReference[0].PaymentServiceItem.MTOServiceItem.MTOShipment.ID = uuid.Nil + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paramsWithBadReference) + suite.Error(err) + suite.IsType(apperror.NotFoundError{}, err) + }) +} +func (suite *GHCRateEngineServiceSuite) TestPriceUsingParamsIDSFSCBelowMinimumWeight() { + pricer := NewInternationalDestinationSITFuelSurchargePricer() + + setupTestData := func() models.PaymentServiceItem { + belowMinWeightBilled := unit.Pound(200) + paymentServiceItem := factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIDSFSC, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameActualPickupDate, + KeyType: models.ServiceItemParamTypeDate, + Value: idsfscActualPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNameDistanceZipSITDest, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idsfscTestDistance)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(belowMinWeightBilled)), + }, + { + Key: models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier, + KeyType: models.ServiceItemParamTypeDecimal, + Value: fmt.Sprintf("%.7f", idsfscWeightDistanceMultiplier), // we need precision 7 to handle values like 0.0006255 + }, + { + Key: models.ServiceItemParamNameEIAFuelPrice, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idsfscFuelPrice)), + }, + }, nil, nil, + ) + + return paymentServiceItem + } + + suite.Run("success using PaymentServiceItemParams with below minimum weight for a PPM shipment", func() { + paymentServiceItem := setupTestData() + paramsWithBelowMinimumWeight := paymentServiceItem.PaymentServiceItemParams + paramsWithBelowMinimumWeight[0].PaymentServiceItem.MTOServiceItem.MTOShipment.ShipmentType = models.MTOShipmentTypePPM + + priceCents, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paramsWithBelowMinimumWeight) + suite.NoError(err) + suite.Equal(idsfscPriceCents, priceCents) + + }) + + suite.Run("fails using PaymentServiceItemParams with below minimum weight for WeightBilled", func() { + paymentServiceItem := setupTestData() + paramsWithBelowMinimumWeight := paymentServiceItem.PaymentServiceItemParams + + priceCents, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paramsWithBelowMinimumWeight) + if suite.Error(err) { + suite.Equal("Weight must be a minimum of 500", err.Error()) + suite.Equal(unit.Cents(0), priceCents) + } + }) + +} diff --git a/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go b/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go new file mode 100644 index 00000000000..61b100bc040 --- /dev/null +++ b/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go @@ -0,0 +1,109 @@ +package ghcrateengine + +import ( + "database/sql" + "fmt" + "math" + "time" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/apperror" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/unit" +) + +type internationalOriginFuelSurchargePricer struct { +} + +func NewInternationalOriginSITFuelSurchargePricer() services.InternationalOriginSITFuelSurchargePricer { + return &internationalOriginFuelSurchargePricer{} +} + +// Price determines the price for International Origin SIT Fuel Surcharges +func (p internationalOriginFuelSurchargePricer) Price(_ appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { + // Validate parameters + if actualPickupDate.IsZero() { + return 0, nil, errors.New("ActualPickupDate is required") + } + if distance <= 0 { + return 0, nil, errors.New("Distance must be greater than 0") + } + if !isPPM && weight < minInternationalWeight { + return 0, nil, fmt.Errorf("Weight must be a minimum of %d", minInternationalWeight) + } + if fscWeightBasedDistanceMultiplier == 0 { + return 0, nil, errors.New("WeightBasedDistanceMultiplier is required") + } + if eiaFuelPrice == 0 { + return 0, nil, errors.New("EIAFuelPrice is required") + } + + fscPriceDifferenceInCents := (eiaFuelPrice - baseGHCDieselFuelPrice).Float64() / 1000.0 + fscMultiplier := fscWeightBasedDistanceMultiplier * distance.Float64() + fscPrice := fscMultiplier * fscPriceDifferenceInCents * 100 + totalCost := unit.Cents(math.Round(fscPrice)) + + displayParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameFSCPriceDifferenceInCents, Value: FormatFloat(fscPriceDifferenceInCents, 1)}, + {Key: models.ServiceItemParamNameFSCMultiplier, Value: FormatFloat(fscMultiplier, 7)}, + } + + return totalCost, displayParams, nil +} + +func (p internationalOriginFuelSurchargePricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + actualPickupDate, err := getParamTime(params, models.ServiceItemParamNameActualPickupDate) + if err != nil { + return unit.Cents(0), nil, err + } + + var paymentServiceItem models.PaymentServiceItem + mtoShipment := params[0].PaymentServiceItem.MTOServiceItem.MTOShipment + + if mtoShipment.ID == uuid.Nil { + err = appCtx.DB().Eager("MTOServiceItem", "MTOServiceItem.MTOShipment").Find(&paymentServiceItem, params[0].PaymentServiceItemID) + if err != nil { + switch err { + case sql.ErrNoRows: + return unit.Cents(0), nil, apperror.NewNotFoundError(params[0].PaymentServiceItemID, "looking for PaymentServiceItem") + default: + return unit.Cents(0), nil, apperror.NewQueryError("PaymentServiceItem", err, "") + } + } + mtoShipment = paymentServiceItem.MTOServiceItem.MTOShipment + } + + distance, err := getParamInt(params, models.ServiceItemParamNameDistanceZipSITOrigin) + if err != nil { + return unit.Cents(0), nil, err + } + + weightBilled, err := getParamInt(params, models.ServiceItemParamNameWeightBilled) + if err != nil { + return unit.Cents(0), nil, err + } + + fscWeightBasedDistanceMultiplier, err := getParamFloat(params, models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier) + if err != nil { + return unit.Cents(0), nil, err + } + + eiaFuelPrice, err := getParamInt(params, models.ServiceItemParamNameEIAFuelPrice) + if err != nil { + return unit.Cents(0), nil, err + } + + var isPPM = false + if params[0].PaymentServiceItem.MTOServiceItem.MTOShipment.ShipmentType == models.MTOShipmentTypePPM { + // PPMs do not require minimums for a shipment's weight + // this flag is passed into the Price function to ensure the weight min + // are not enforced for PPMs + isPPM = true + } + + return p.Price(appCtx, actualPickupDate, unit.Miles(distance), unit.Pound(weightBilled), fscWeightBasedDistanceMultiplier, unit.Millicents(eiaFuelPrice), isPPM) +} diff --git a/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer_test.go b/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer_test.go new file mode 100644 index 00000000000..bae3fcaa836 --- /dev/null +++ b/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer_test.go @@ -0,0 +1,298 @@ +package ghcrateengine + +import ( + "fmt" + "time" + + "github.com/gofrs/uuid" + + "github.com/transcom/mymove/pkg/apperror" + "github.com/transcom/mymove/pkg/factory" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/testdatagen" + "github.com/transcom/mymove/pkg/unit" +) + +const ( + iosfscTestDistance = unit.Miles(2276) + iosfscTestWeight = unit.Pound(4025) + iosfscWeightDistanceMultiplier = float64(0.000417) + iosfscFuelPrice = unit.Millicents(281400) + iosfscPriceCents = unit.Cents(2980) +) + +var iosfscActualPickupDate = time.Date(testdatagen.TestYear, time.June, 5, 7, 33, 11, 456, time.UTC) + +func (suite *GHCRateEngineServiceSuite) TestPriceInternationalOriginSITFuelSurcharge() { + pricer := NewInternationalOriginSITFuelSurchargePricer() + + suite.Run("success without PaymentServiceItemParams", func() { + isPPM := false + priceCents, _, err := pricer.Price(suite.AppContextForTest(), iosfscActualPickupDate, iosfscTestDistance, iosfscTestWeight, iosfscWeightDistanceMultiplier, iosfscFuelPrice, isPPM) + suite.NoError(err) + suite.Equal(iosfscPriceCents, priceCents) + }) + + suite.Run("success without PaymentServiceItemParams when shipment is PPM with < 500 lb weight", func() { + isPPM := true + priceCents, _, err := pricer.Price(suite.AppContextForTest(), iosfscActualPickupDate, iosfscTestDistance, unit.Pound(250), iosfscWeightDistanceMultiplier, iosfscFuelPrice, isPPM) + suite.NoError(err) + suite.Equal(iosfscPriceCents, priceCents) + }) + + suite.Run("IOSFSC is negative if fuel price from EIA is below $2.50", func() { + isPPM := false + priceCents, _, err := pricer.Price(suite.AppContextForTest(), iosfscActualPickupDate, iosfscTestDistance, iosfscTestWeight, iosfscWeightDistanceMultiplier, 242400, isPPM) + suite.NoError(err) + suite.Equal(unit.Cents(-721), priceCents) + }) + + suite.Run("Price validation errors", func() { + type priceArgs struct { + actualPickupDate time.Time + distance unit.Miles + weight unit.Pound + fscWeightBasedDistanceMultiplier float64 + eiaFuelPrice unit.Millicents + isPPM bool + } + + testCases := map[string]struct { + priceArgs priceArgs + errorMessage string + }{ + "Missing ActualPickupDate": { + priceArgs: priceArgs{ + actualPickupDate: time.Time{}, + distance: iosfscTestDistance, + weight: iosfscTestWeight, + fscWeightBasedDistanceMultiplier: iosfscWeightDistanceMultiplier, + eiaFuelPrice: iosfscFuelPrice, + isPPM: false, + }, + errorMessage: "ActualPickupDate is required", + }, + "Below minimum weight": { + priceArgs: priceArgs{ + actualPickupDate: iosfscActualPickupDate, + distance: iosfscTestDistance, + weight: unit.Pound(0), + fscWeightBasedDistanceMultiplier: iosfscWeightDistanceMultiplier, + eiaFuelPrice: iosfscFuelPrice, + isPPM: false, + }, + errorMessage: fmt.Sprintf("Weight must be a minimum of %d", minInternationalWeight), + }, + "Missing FSCWeightBasedDistanceMultiplier": { + priceArgs: priceArgs{ + actualPickupDate: iosfscActualPickupDate, + distance: iosfscTestDistance, + weight: iosfscTestWeight, + fscWeightBasedDistanceMultiplier: 0, + eiaFuelPrice: iosfscFuelPrice, + isPPM: false, + }, + errorMessage: "WeightBasedDistanceMultiplier is required", + }, + "Missing EIAFuelPrice": { + priceArgs: priceArgs{ + actualPickupDate: iosfscActualPickupDate, + distance: iosfscTestDistance, + weight: iosfscTestWeight, + fscWeightBasedDistanceMultiplier: iosfscWeightDistanceMultiplier, + eiaFuelPrice: 0, + isPPM: false, + }, + errorMessage: "EIAFuelPrice is required", + }, + "Missing Distance": { + priceArgs: priceArgs{ + actualPickupDate: iosfscActualPickupDate, + distance: unit.Miles(0), + weight: iosfscTestWeight, + fscWeightBasedDistanceMultiplier: iosfscWeightDistanceMultiplier, + eiaFuelPrice: iosfscFuelPrice, + isPPM: false, + }, + errorMessage: "Distance must be greater than 0", + }, + } + + for name, testcase := range testCases { + suite.Run(name, func() { + _, _, err := pricer.Price(suite.AppContextForTest(), testcase.priceArgs.actualPickupDate, testcase.priceArgs.distance, testcase.priceArgs.weight, testcase.priceArgs.fscWeightBasedDistanceMultiplier, testcase.priceArgs.eiaFuelPrice, testcase.priceArgs.isPPM) + suite.Error(err) + suite.Equal(testcase.errorMessage, err.Error()) + }) + } + }) +} + +func (suite *GHCRateEngineServiceSuite) TestPriceUsingParamsInternationalOriginSITFuelSurcharge() { + pricer := NewInternationalOriginSITFuelSurchargePricer() + + fscPriceDifferenceInCents := (iosfscFuelPrice - baseGHCDieselFuelPrice).Float64() / 1000.0 + fscMultiplier := iosfscWeightDistanceMultiplier * iosfscTestDistance.Float64() + + setupTestData := func() models.PaymentServiceItem { + paymentServiceItem := factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIOSFSC, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameActualPickupDate, + KeyType: models.ServiceItemParamTypeDate, + Value: iosfscActualPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNameDistanceZipSITOrigin, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(iosfscTestDistance)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(iosfscTestWeight)), + }, + { + Key: models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier, + KeyType: models.ServiceItemParamTypeDecimal, + Value: fmt.Sprintf("%.7f", iosfscWeightDistanceMultiplier), // we need precision 7 to handle values like 0.0006255 + }, + { + Key: models.ServiceItemParamNameEIAFuelPrice, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(iosfscFuelPrice)), + }, + }, nil, nil, + ) + + return paymentServiceItem + } + + suite.Run("success using PaymentServiceItemParams", func() { + paymentServiceItem := setupTestData() + priceCents, displayParams, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.NoError(err) + suite.Equal(iosfscPriceCents, priceCents) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameFSCPriceDifferenceInCents, Value: FormatFloat(fscPriceDifferenceInCents, 1)}, + {Key: models.ServiceItemParamNameFSCMultiplier, Value: FormatFloat(fscMultiplier, 7)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("PriceUsingParams validation errors", func() { + testCases := map[string]struct { + missingPaymentServiceItem models.ServiceItemParamName + errorMessage string + }{ + "Missing ActualPickupDate": { + missingPaymentServiceItem: models.ServiceItemParamNameActualPickupDate, + errorMessage: "could not find param with key ActualPickupDate", + }, + "Missing WeightBilled": { + missingPaymentServiceItem: models.ServiceItemParamNameWeightBilled, + errorMessage: "could not find param with key WeightBilled", + }, + "Missing FSCWeightBasedDistanceMultiplier": { + missingPaymentServiceItem: models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier, + errorMessage: "could not find param with key FSCWeightBasedDistanceMultiplier", + }, + "Missing EIAFuelPrice": { + missingPaymentServiceItem: models.ServiceItemParamNameEIAFuelPrice, + errorMessage: "could not find param with key EIAFuelPrice", + }, + "Missing Distance": { + missingPaymentServiceItem: models.ServiceItemParamNameDistanceZipSITOrigin, + errorMessage: "could not find param with key DistanceZipSITOrigin", + }, + } + + for name, testcase := range testCases { + suite.Run(name, func() { + paymentServiceItem := setupTestData() + params := suite.removeOnePaymentServiceItem(paymentServiceItem.PaymentServiceItemParams, testcase.missingPaymentServiceItem) + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), params) + suite.Error(err) + suite.Equal(testcase.errorMessage, err.Error()) + }) + } + }) + + suite.Run("not found error on PaymentServiceItem", func() { + paymentServiceItem := setupTestData() + paramsWithBadReference := paymentServiceItem.PaymentServiceItemParams + paramsWithBadReference[0].PaymentServiceItemID = uuid.Nil + // Pricer only searches for the shipment when the ID is nil + paramsWithBadReference[0].PaymentServiceItem.MTOServiceItem.MTOShipment.ID = uuid.Nil + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paramsWithBadReference) + suite.Error(err) + suite.IsType(apperror.NotFoundError{}, err) + }) +} +func (suite *GHCRateEngineServiceSuite) TestPriceUsingParamsIOSFSCBelowMinimumWeight() { + pricer := NewInternationalOriginSITFuelSurchargePricer() + + setupTestData := func() models.PaymentServiceItem { + belowMinWeightBilled := unit.Pound(200) + paymentServiceItem := factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIOSFSC, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameActualPickupDate, + KeyType: models.ServiceItemParamTypeDate, + Value: iosfscActualPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNameDistanceZipSITOrigin, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(iosfscTestDistance)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(belowMinWeightBilled)), + }, + { + Key: models.ServiceItemParamNameFSCWeightBasedDistanceMultiplier, + KeyType: models.ServiceItemParamTypeDecimal, + Value: fmt.Sprintf("%.7f", iosfscWeightDistanceMultiplier), // we need precision 7 to handle values like 0.0006255 + }, + { + Key: models.ServiceItemParamNameEIAFuelPrice, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(iosfscFuelPrice)), + }, + }, nil, nil, + ) + + return paymentServiceItem + } + + suite.Run("success using PaymentServiceItemParams with below minimum weight for a PPM shipment", func() { + paymentServiceItem := setupTestData() + paramsWithBelowMinimumWeight := paymentServiceItem.PaymentServiceItemParams + paramsWithBelowMinimumWeight[0].PaymentServiceItem.MTOServiceItem.MTOShipment.ShipmentType = models.MTOShipmentTypePPM + + priceCents, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paramsWithBelowMinimumWeight) + suite.NoError(err) + suite.Equal(iosfscPriceCents, priceCents) + + }) + + suite.Run("fails using PaymentServiceItemParams with below minimum weight for WeightBilled", func() { + paymentServiceItem := setupTestData() + paramsWithBelowMinimumWeight := paymentServiceItem.PaymentServiceItemParams + + priceCents, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paramsWithBelowMinimumWeight) + if suite.Error(err) { + suite.Equal("Weight must be a minimum of 500", err.Error()) + suite.Equal(unit.Cents(0), priceCents) + } + }) + +} diff --git a/pkg/services/ghcrateengine/service_item_pricer.go b/pkg/services/ghcrateengine/service_item_pricer.go index e48f3cdc749..b56bc2402f5 100644 --- a/pkg/services/ghcrateengine/service_item_pricer.go +++ b/pkg/services/ghcrateengine/service_item_pricer.go @@ -89,6 +89,10 @@ func PricerForServiceItem(serviceCode models.ReServiceCode) (services.ParamsPric return NewDomesticOriginSITFuelSurchargePricer(), nil case models.ReServiceCodeDDSFSC: return NewDomesticDestinationSITFuelSurchargePricer(), nil + case models.ReServiceCodeIOSFSC: + return NewInternationalOriginSITFuelSurchargePricer(), nil + case models.ReServiceCodeIDSFSC: + return NewInternationalDestinationSITFuelSurchargePricer(), nil case models.ReServiceCodeDOASIT: return NewDomesticOriginAdditionalDaysSITPricer(), nil case models.ReServiceCodeDDASIT: diff --git a/pkg/services/shipment_address_update/shipment_address_update_requester.go b/pkg/services/shipment_address_update/shipment_address_update_requester.go index 0cb8ba3c123..2bd3530fdd0 100644 --- a/pkg/services/shipment_address_update/shipment_address_update_requester.go +++ b/pkg/services/shipment_address_update/shipment_address_update_requester.go @@ -178,7 +178,11 @@ func (f *shipmentAddressUpdateRequester) doesShipmentContainApprovedDestinationS for _, serviceItem := range serviceItems { serviceCode := serviceItem.ReService.Code status := serviceItem.Status - if (serviceCode == models.ReServiceCodeDDASIT || serviceCode == models.ReServiceCodeDDDSIT || serviceCode == models.ReServiceCodeDDFSIT || serviceCode == models.ReServiceCodeDDSFSC) && + if (serviceCode == models.ReServiceCodeDDASIT || + serviceCode == models.ReServiceCodeDDDSIT || + serviceCode == models.ReServiceCodeDDFSIT || + serviceCode == models.ReServiceCodeDDSFSC || + serviceCode == models.ReServiceCodeIDSFSC) && status == models.MTOServiceItemStatusApproved { return true } From 06e77d688fcba35eb6937509c20cd0f3f8363c4c Mon Sep 17 00:00:00 2001 From: Michael Inthavongsay Date: Tue, 4 Mar 2025 17:12:30 -0500 Subject: [PATCH 2/5] estimate and actual pricing calculations for international origin/dest fuel surcharge --- ...3413_fn_update_service_item_pricing.up.sql | 51 ++-- migrations/app/dml_migrations_manifest.txt | 1 + ...d_param_and_remove_ones__not_needed.up.sql | 12 + .../primeapi/mto_service_item_test.go | 1 - .../service_param_value_lookups.go | 13 +- .../weight_billed_lookup.go | 8 +- ...l_destination_sit_fuel_surcharge_pricer.go | 34 +-- ...tional_origin_sit_fuel_surcharge_pricer.go | 34 +-- .../ghcrateengine/pricer_helpers_intl.go | 31 ++ .../mto_service_item_creator.go | 53 +++- .../mto_service_item_creator_test.go | 267 ++++++++++++++++++ .../mto_service_item_updater_test.go | 1 + .../mto_service_item_validators_test.go | 71 +---- 13 files changed, 406 insertions(+), 171 deletions(-) create mode 100644 migrations/app/schema/20250303210036_B-22462_service_params_add_param_and_remove_ones__not_needed.up.sql diff --git a/migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql b/migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql index 98cec1041ba..ad42ab934c7 100644 --- a/migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql +++ b/migrations/app/ddl_migrations/ddl_functions/20250221213413_fn_update_service_item_pricing.up.sql @@ -1,6 +1,5 @@ --B-22462 M.Inthavongsay Adding initial migration file for update_service_item_pricing stored procedure using new migration process. --Also updating to allow IOSFSC and IDSFSC SIT service items. - CREATE OR REPLACE PROCEDURE update_service_item_pricing( shipment_id UUID, mileage INT @@ -39,7 +38,7 @@ BEGIN -- loop through service items in the shipment FOR service_item IN - SELECT si.id, si.re_service_id + SELECT si.id, si.re_service_id, si.sit_delivery_miles FROM mto_service_items si WHERE si.mto_shipment_id = shipment_id LOOP @@ -60,10 +59,10 @@ BEGIN -- multiply by 110% of estimated weight estimated_price := ROUND((escalated_price * (shipment.prime_estimated_weight * 1.1) / 100), 2) * 100; RAISE NOTICE ''%: Received estimated price of % (% * (% * 1.1) / 100)) cents'', service_code, estimated_price, escalated_price, shipment.prime_estimated_weight; - -- update the pricing_estimate value in mto_service_items - UPDATE mto_service_items - SET pricing_estimate = estimated_price - WHERE id = service_item.id; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; END IF; WHEN service_code IN (''IHPK'', ''IUBPK'', ''IOSHUT'') THEN @@ -76,10 +75,10 @@ BEGIN -- multiply by 110% of estimated weight estimated_price := ROUND((escalated_price * (shipment.prime_estimated_weight * 1.1) / 100), 2) * 100; RAISE NOTICE ''%: Received estimated price of % (% * (% * 1.1) / 100)) cents'', service_code, estimated_price, escalated_price, shipment.prime_estimated_weight; - -- update the pricing_estimate value in mto_service_items - UPDATE mto_service_items - SET pricing_estimate = estimated_price - WHERE id = service_item.id; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; END IF; WHEN service_code IN (''IHUPK'', ''IUBUPK'', ''IDSHUT'') THEN @@ -92,10 +91,10 @@ BEGIN -- multiply by 110% of estimated weight estimated_price := ROUND((escalated_price * (shipment.prime_estimated_weight * 1.1) / 100), 2) * 100; RAISE NOTICE ''%: Received estimated price of % (% * (% * 1.1) / 100)) cents'', service_code, estimated_price, escalated_price, shipment.prime_estimated_weight; - -- update the pricing_estimate value in mto_service_items - UPDATE mto_service_items - SET pricing_estimate = estimated_price - WHERE id = service_item.id; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; END IF; WHEN service_code IN (''POEFSC'', ''PODFSC'') THEN @@ -115,17 +114,17 @@ BEGIN estimated_price := ROUND((cents_above_baseline * price_difference) * 100); RAISE NOTICE ''Received estimated price of % cents for service_code: %.'', estimated_price, service_code; - -- update the pricing_estimate value in mto_service_items - UPDATE mto_service_items - SET pricing_estimate = estimated_price - WHERE id = service_item.id; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; END IF; WHEN service_code IN (''IOSFSC'', ''IDSFSC'') THEN - distance = mileage; + distance = service_item.sit_delivery_miles; - -- getting FSC multiplier from re_fsc_multipliers - estimated_fsc_multiplier := get_fsc_multiplier(shipment.prime_estimated_weight); + -- getting FSC multiplier from re_fsc_multipliers. inflate estimated weight by 10%. + estimated_fsc_multiplier := get_fsc_multiplier(CAST((shipment.prime_estimated_weight * 1.1) as INTEGER)); fuel_price := get_fuel_price(shipment.requested_pickup_date); @@ -138,11 +137,13 @@ BEGIN estimated_price := ROUND((cents_above_baseline * price_difference) * 100); RAISE NOTICE ''Received estimated price of % cents for service_code: %.'', estimated_price, service_code; - -- update the pricing_estimate value in mto_service_items - UPDATE mto_service_items - SET pricing_estimate = estimated_price - WHERE id = service_item.id; + -- update the pricing_estimate value in mto_service_items + UPDATE mto_service_items + SET pricing_estimate = estimated_price + WHERE id = service_item.id; END IF; + ELSE + -- DEFAULT HERE END CASE; END LOOP; END; diff --git a/migrations/app/dml_migrations_manifest.txt b/migrations/app/dml_migrations_manifest.txt index 570749e1cfa..fdcd955e774 100644 --- a/migrations/app/dml_migrations_manifest.txt +++ b/migrations/app/dml_migrations_manifest.txt @@ -1,3 +1,4 @@ # This is the migrations manifest. # If a migration is not recorded here, then it will error. # Naming convention: 202502201325_B-123456_update_some_table.up.sql running will create this file. +20250303210036_B-22462_service_params_add_param_and_remove_ones__not_needed.up.sql diff --git a/migrations/app/schema/20250303210036_B-22462_service_params_add_param_and_remove_ones__not_needed.up.sql b/migrations/app/schema/20250303210036_B-22462_service_params_add_param_and_remove_ones__not_needed.up.sql new file mode 100644 index 00000000000..2de95514acc --- /dev/null +++ b/migrations/app/schema/20250303210036_B-22462_service_params_add_param_and_remove_ones__not_needed.up.sql @@ -0,0 +1,12 @@ +-- Remove DistanceZipSITOrigin service lookup for IDSFSC +delete from service_params where service_id = (select id from re_services where code = 'IDSFSC') and service_item_param_key_id in +(select id from service_item_param_keys where key = 'DistanceZipSITOrigin'); + +-- Remove ZipSITOriginHHGOriginalAddress service lookup for IDSFSC +delete from service_params where service_id = (select id from re_services where code = 'IDSFSC') and service_item_param_key_id in +(select id from service_item_param_keys where key = 'ZipSITOriginHHGOriginalAddress'); + +-- Associate DistanceZipSITDest to service lookup for IDSFSC. +INSERT INTO service_params (id, service_id, service_item_param_key_id, created_at, updated_at, is_optional) +VALUES + ('15c5ff37-99db-d162-4202-44f45181588a', (SELECT id FROM re_services WHERE code = 'IDSFSC'), (SELECT id FROM service_item_param_keys WHERE key = 'DistanceZipSITDest'), now(), now(), false) diff --git a/pkg/handlers/primeapi/mto_service_item_test.go b/pkg/handlers/primeapi/mto_service_item_test.go index 68fe0b8e23b..92a79ded494 100644 --- a/pkg/handlers/primeapi/mto_service_item_test.go +++ b/pkg/handlers/primeapi/mto_service_item_test.go @@ -181,7 +181,6 @@ func (suite *HandlerSuite) TestCreateMTOServiceItemHandler() { mock.Anything, mock.Anything, false, - false, ).Return(400, nil) creator := mtoserviceitem.NewMTOServiceItemCreator(planner, builder, moveRouter, ghcrateengine.NewDomesticUnpackPricer(), ghcrateengine.NewDomesticPackPricer(), ghcrateengine.NewDomesticLinehaulPricer(), ghcrateengine.NewDomesticShorthaulPricer(), ghcrateengine.NewDomesticOriginPricer(), ghcrateengine.NewDomesticDestinationPricer(), ghcrateengine.NewFuelSurchargePricer()) handler := CreateMTOServiceItemHandler{ diff --git a/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go b/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go index 242340c3085..c4781646826 100644 --- a/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go +++ b/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go @@ -148,7 +148,10 @@ func ServiceParamLookupInitialize( return nil, err } serviceItemDimensions = mtoServiceItem.Dimensions - case models.ReServiceCodeDDASIT, models.ReServiceCodeDDDSIT, models.ReServiceCodeDDFSIT, models.ReServiceCodeDDSFSC: + case models.ReServiceCodeDDASIT, models.ReServiceCodeDDDSIT, + models.ReServiceCodeDDFSIT, models.ReServiceCodeDDSFSC, + models.ReServiceCodeIDASIT, models.ReServiceCodeIDDSIT, + models.ReServiceCodeIDFSIT, models.ReServiceCodeIDSFSC: // load destination address from final address on service item if mtoServiceItem.SITDestinationFinalAddressID != nil && *mtoServiceItem.SITDestinationFinalAddressID != uuid.Nil { err := appCtx.DB().Load(&mtoServiceItem, "SITDestinationFinalAddress") @@ -240,6 +243,9 @@ func ServiceParamLookupInitialize( paramKeyLookups := InitializeLookups(appCtx, mtoShipment, mtoServiceItem) for _, paramKeyName := range ServiceItemParamsWithLookups { + if paramKeyName == "ZipSITOriginHHGActualAddress" { + println(paramKeyName) + } lookup, ok := paramKeyLookups[paramKeyName] if !ok { return nil, fmt.Errorf("no lookup was found for service item param key name %s", paramKeyName) @@ -605,7 +611,10 @@ func getDestinationAddressForService(appCtx appcontext.AppContext, serviceCode m } switch siCopy.ReService.Code { - case models.ReServiceCodeDDASIT, models.ReServiceCodeDDDSIT, models.ReServiceCodeDDFSIT, models.ReServiceCodeDDSFSC: + case models.ReServiceCodeDDASIT, models.ReServiceCodeDDDSIT, + models.ReServiceCodeDDFSIT, models.ReServiceCodeDDSFSC, + models.ReServiceCodeIDASIT, models.ReServiceCodeIDDSIT, + models.ReServiceCodeIDFSIT, models.ReServiceCodeIDSFSC: if shipmentCopy.DeliveryAddressUpdate != nil && shipmentCopy.DeliveryAddressUpdate.Status == models.ShipmentAddressUpdateStatusApproved { if siCopy.ApprovedAt != nil && shipmentCopy.DeliveryAddressUpdate.UpdatedAt.After(*siCopy.ApprovedAt) { return shipmentCopy.DeliveryAddressUpdate.OriginalAddress, nil diff --git a/pkg/payment_request/service_param_value_lookups/weight_billed_lookup.go b/pkg/payment_request/service_param_value_lookups/weight_billed_lookup.go index 2a3e2f8bb62..8b2c2611bb8 100644 --- a/pkg/payment_request/service_param_value_lookups/weight_billed_lookup.go +++ b/pkg/payment_request/service_param_value_lookups/weight_billed_lookup.go @@ -52,7 +52,9 @@ func (r WeightBilledLookup) lookup(appCtx appcontext.AppContext, keyData *Servic return value, nil case models.ReServiceCodeDDSFSC, models.ReServiceCodeDOSFSC, - models.ReServiceCodeFSC: + models.ReServiceCodeFSC, + models.ReServiceCodeIDSFSC, + models.ReServiceCodeIOSFSC: var weightBilled string @@ -245,7 +247,9 @@ func applyMinimum(code models.ReServiceCode, shipmentType models.MTOShipmentType models.ReServiceCodeIDSHUT, models.ReServiceCodeFSC, models.ReServiceCodePODFSC, - models.ReServiceCodePOEFSC: + models.ReServiceCodePOEFSC, + models.ReServiceCodeIOSFSC, + models.ReServiceCodeIDSFSC: if weight < 500 { result = 500 } diff --git a/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go b/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go index 313ce85cd89..67080166570 100644 --- a/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go +++ b/pkg/services/ghcrateengine/international_destination_sit_fuel_surcharge_pricer.go @@ -2,12 +2,9 @@ package ghcrateengine import ( "database/sql" - "fmt" - "math" "time" "github.com/gofrs/uuid" - "github.com/pkg/errors" "github.com/transcom/mymove/pkg/appcontext" "github.com/transcom/mymove/pkg/apperror" @@ -23,35 +20,8 @@ func NewInternationalDestinationSITFuelSurchargePricer() services.InternationalD return &internationalDestinationSITFuelSurchargePricer{} } -func (p internationalDestinationSITFuelSurchargePricer) Price(_ appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { - // Validate parameters - if actualPickupDate.IsZero() { - return 0, nil, errors.New("ActualPickupDate is required") - } - if distance <= 0 { - return 0, nil, errors.New("Distance must be greater than 0") - } - if !isPPM && weight < minInternationalWeight { - return 0, nil, fmt.Errorf("Weight must be a minimum of %d", minInternationalWeight) - } - if fscWeightBasedDistanceMultiplier == 0 { - return 0, nil, errors.New("WeightBasedDistanceMultiplier is required") - } - if eiaFuelPrice == 0 { - return 0, nil, errors.New("EIAFuelPrice is required") - } - - fscPriceDifferenceInCents := (eiaFuelPrice - baseGHCDieselFuelPrice).Float64() / 1000.0 - fscMultiplier := fscWeightBasedDistanceMultiplier * distance.Float64() - fscPrice := fscMultiplier * fscPriceDifferenceInCents * 100 - totalCost := unit.Cents(math.Round(fscPrice)) - - displayParams := services.PricingDisplayParams{ - {Key: models.ServiceItemParamNameFSCPriceDifferenceInCents, Value: FormatFloat(fscPriceDifferenceInCents, 1)}, - {Key: models.ServiceItemParamNameFSCMultiplier, Value: FormatFloat(fscMultiplier, 7)}, - } - - return totalCost, displayParams, nil +func (p internationalDestinationSITFuelSurchargePricer) Price(appCtx appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { + return priceIntlFuelSurcharge(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) } func (p internationalDestinationSITFuelSurchargePricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { diff --git a/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go b/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go index 61b100bc040..73e9d9bad77 100644 --- a/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go +++ b/pkg/services/ghcrateengine/international_origin_sit_fuel_surcharge_pricer.go @@ -2,12 +2,9 @@ package ghcrateengine import ( "database/sql" - "fmt" - "math" "time" "github.com/gofrs/uuid" - "github.com/pkg/errors" "github.com/transcom/mymove/pkg/appcontext" "github.com/transcom/mymove/pkg/apperror" @@ -24,35 +21,8 @@ func NewInternationalOriginSITFuelSurchargePricer() services.InternationalOrigin } // Price determines the price for International Origin SIT Fuel Surcharges -func (p internationalOriginFuelSurchargePricer) Price(_ appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { - // Validate parameters - if actualPickupDate.IsZero() { - return 0, nil, errors.New("ActualPickupDate is required") - } - if distance <= 0 { - return 0, nil, errors.New("Distance must be greater than 0") - } - if !isPPM && weight < minInternationalWeight { - return 0, nil, fmt.Errorf("Weight must be a minimum of %d", minInternationalWeight) - } - if fscWeightBasedDistanceMultiplier == 0 { - return 0, nil, errors.New("WeightBasedDistanceMultiplier is required") - } - if eiaFuelPrice == 0 { - return 0, nil, errors.New("EIAFuelPrice is required") - } - - fscPriceDifferenceInCents := (eiaFuelPrice - baseGHCDieselFuelPrice).Float64() / 1000.0 - fscMultiplier := fscWeightBasedDistanceMultiplier * distance.Float64() - fscPrice := fscMultiplier * fscPriceDifferenceInCents * 100 - totalCost := unit.Cents(math.Round(fscPrice)) - - displayParams := services.PricingDisplayParams{ - {Key: models.ServiceItemParamNameFSCPriceDifferenceInCents, Value: FormatFloat(fscPriceDifferenceInCents, 1)}, - {Key: models.ServiceItemParamNameFSCMultiplier, Value: FormatFloat(fscMultiplier, 7)}, - } - - return totalCost, displayParams, nil +func (p internationalOriginFuelSurchargePricer) Price(appCtx appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { + return priceIntlFuelSurcharge(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) } func (p internationalOriginFuelSurchargePricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { diff --git a/pkg/services/ghcrateengine/pricer_helpers_intl.go b/pkg/services/ghcrateengine/pricer_helpers_intl.go index 195dc117b1c..8ff0d92b0fd 100644 --- a/pkg/services/ghcrateengine/pricer_helpers_intl.go +++ b/pkg/services/ghcrateengine/pricer_helpers_intl.go @@ -282,3 +282,34 @@ func priceIntlCratingUncrating(appCtx appcontext.AppContext, cratingUncratingCod return totalCost, displayParams, nil } + +func priceIntlFuelSurcharge(_ appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { + // Validate parameters + if actualPickupDate.IsZero() { + return 0, nil, errors.New("ActualPickupDate is required") + } + if distance <= 0 { + return 0, nil, errors.New("Distance must be greater than 0") + } + if !isPPM && weight < minInternationalWeight { + return 0, nil, fmt.Errorf("Weight must be a minimum of %d", minInternationalWeight) + } + if fscWeightBasedDistanceMultiplier == 0 { + return 0, nil, errors.New("WeightBasedDistanceMultiplier is required") + } + if eiaFuelPrice == 0 { + return 0, nil, errors.New("EIAFuelPrice is required") + } + + fscPriceDifferenceInCents := (eiaFuelPrice - baseGHCDieselFuelPrice).Float64() / 1000.0 + fscMultiplier := fscWeightBasedDistanceMultiplier * distance.Float64() + fscPrice := fscMultiplier * fscPriceDifferenceInCents * 100 + totalCost := unit.Cents(math.Round(fscPrice)) + + displayParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameFSCPriceDifferenceInCents, Value: FormatFloat(fscPriceDifferenceInCents, 1)}, + {Key: models.ServiceItemParamNameFSCMultiplier, Value: FormatFloat(fscMultiplier, 7)}, + } + + return totalCost, displayParams, nil +} diff --git a/pkg/services/mto_service_item/mto_service_item_creator.go b/pkg/services/mto_service_item/mto_service_item_creator.go index d4966974691..f6322ab9e98 100644 --- a/pkg/services/mto_service_item/mto_service_item_creator.go +++ b/pkg/services/mto_service_item/mto_service_item_creator.go @@ -316,6 +316,35 @@ func (o *mtoServiceItemCreator) calculateSITDeliveryMiles(appCtx appcontext.AppC distance, err = o.planner.ZipTransitDistance(appCtx, mtoShipment.DestinationAddress.PostalCode, serviceItem.SITDestinationFinalAddress.PostalCode, false) } } + + if mtoShipment.MarketCode == models.MarketCodeInternational { + // International Origin SITs + if serviceItem.ReService.Code == models.ReServiceCodeIOFSIT || + serviceItem.ReService.Code == models.ReServiceCodeIOASIT || + serviceItem.ReService.Code == models.ReServiceCodeIOSFSC || + serviceItem.ReService.Code == models.ReServiceCodeIOPSIT { + // Determine distance calculation only if pickup address is CONUS if shipment is OCONUS. + if serviceItem.SITOriginHHGOriginalAddress != nil && + serviceItem.SITOriginHHGActualAddress != nil && + !(*serviceItem.SITOriginHHGOriginalAddress.IsOconus) { + distance, err = o.planner.ZipTransitDistance(appCtx, serviceItem.SITOriginHHGOriginalAddress.PostalCode, serviceItem.SITOriginHHGActualAddress.PostalCode, false) + } + } + + // International Destination SITs + if serviceItem.ReService.Code == models.ReServiceCodeIDFSIT || + serviceItem.ReService.Code == models.ReServiceCodeIDASIT || + serviceItem.ReService.Code == models.ReServiceCodeIDSFSC || + serviceItem.ReService.Code == models.ReServiceCodeIDDSIT { + // Determine distance calculation only if destination address is CONUS if shipment is OCONUS. + if mtoShipment.DestinationAddress != nil && + serviceItem.SITDestinationFinalAddress != nil && + !(*mtoShipment.DestinationAddress.IsOconus) { + distance, err = o.planner.ZipTransitDistance(appCtx, mtoShipment.DestinationAddress.PostalCode, serviceItem.SITDestinationFinalAddress.PostalCode, false) + } + } + } + if err != nil { return 0, err } @@ -453,6 +482,8 @@ func (o *mtoServiceItemCreator) CreateMTOServiceItem(appCtx appcontext.AppContex // which will later populate the additional dest SIT service items as well if (serviceItem.ReService.Code == models.ReServiceCodeDDFSIT || serviceItem.ReService.Code == models.ReServiceCodeIDFSIT) && mtoShipment.DestinationAddressID != nil { + serviceItem.SITDestinationOriginalAddress = mtoShipment.DestinationAddress + serviceItem.SITDestinationOriginalAddressID = mtoShipment.DestinationAddressID serviceItem.SITDestinationFinalAddress = mtoShipment.DestinationAddress serviceItem.SITDestinationFinalAddressID = mtoShipment.DestinationAddressID } @@ -595,38 +626,42 @@ func (o *mtoServiceItemCreator) CreateMTOServiceItem(appCtx appcontext.AppContex extraServiceItem.ReService.Code == models.ReServiceCodeIDDSIT || extraServiceItem.ReService.Code == models.ReServiceCodeIDASIT || extraServiceItem.ReService.Code == models.ReServiceCodeIDSFSC { + extraServiceItem.SITDestinationOriginalAddress = serviceItem.SITDestinationOriginalAddress + extraServiceItem.SITDestinationOriginalAddressID = serviceItem.SITDestinationOriginalAddressID extraServiceItem.SITDestinationFinalAddress = serviceItem.SITDestinationFinalAddress extraServiceItem.SITDestinationFinalAddressID = serviceItem.SITDestinationFinalAddressID } } } - milesCalculated, errCalcSITDelivery := o.calculateSITDeliveryMiles(appCtx, serviceItem, mtoShipment) + milesCalculatedOrigin, errCalcSITDelivery := o.calculateSITDeliveryMiles(appCtx, serviceItem, mtoShipment) - // only calculate SITDeliveryMiles for DOPSIT and DOSFSC origin service items + // only calculate SITDeliveryMiles for DOPSIT/DOSFSC, IOPSIT/IOSFSC origin service items if (serviceItem.ReService.Code == models.ReServiceCodeDOFSIT || serviceItem.ReService.Code == models.ReServiceCodeIOFSIT) && - milesCalculated != 0 { + milesCalculatedOrigin != 0 { for itemIndex := range *extraServiceItems { extraServiceItem := &(*extraServiceItems)[itemIndex] if extraServiceItem.ReService.Code == models.ReServiceCodeDOPSIT || extraServiceItem.ReService.Code == models.ReServiceCodeIOPSIT || extraServiceItem.ReService.Code == models.ReServiceCodeDOSFSC || extraServiceItem.ReService.Code == models.ReServiceCodeIOSFSC { - if milesCalculated > 0 && errCalcSITDelivery == nil { - extraServiceItem.SITDeliveryMiles = &milesCalculated + if milesCalculatedOrigin > 0 && errCalcSITDelivery == nil { + extraServiceItem.SITDeliveryMiles = &milesCalculatedOrigin } } } } - // only calculate SITDeliveryMiles for DDDSIT and DDSFSC destination service items - if (serviceItem.ReService.Code == models.ReServiceCodeDDFSIT || serviceItem.ReService.Code == models.ReServiceCodeIDFSIT) && milesCalculated != 0 { + milesCalculatedDestination, errCalcSITDelivery := o.calculateSITDeliveryMiles(appCtx, serviceItem, mtoShipment) + + // only calculate SITDeliveryMiles for DDDSIT/DDSFSC, IDDSIT/IDSFSC destination service items + if (serviceItem.ReService.Code == models.ReServiceCodeDDFSIT || serviceItem.ReService.Code == models.ReServiceCodeIDFSIT) && milesCalculatedDestination != 0 { for itemIndex := range *extraServiceItems { extraServiceItem := &(*extraServiceItems)[itemIndex] if extraServiceItem.ReService.Code == models.ReServiceCodeDDDSIT || extraServiceItem.ReService.Code == models.ReServiceCodeIDDSIT || extraServiceItem.ReService.Code == models.ReServiceCodeDDSFSC || extraServiceItem.ReService.Code == models.ReServiceCodeIDSFSC { - if milesCalculated > 0 && errCalcSITDelivery == nil { - extraServiceItem.SITDeliveryMiles = &milesCalculated + if milesCalculatedDestination > 0 && errCalcSITDelivery == nil { + extraServiceItem.SITDeliveryMiles = &milesCalculatedDestination } } } diff --git a/pkg/services/mto_service_item/mto_service_item_creator_test.go b/pkg/services/mto_service_item/mto_service_item_creator_test.go index 463e06e9090..3ca96a07823 100644 --- a/pkg/services/mto_service_item/mto_service_item_creator_test.go +++ b/pkg/services/mto_service_item/mto_service_item_creator_test.go @@ -1073,6 +1073,8 @@ func (suite *MTOServiceItemServiceSuite) TestCreateOriginSITServiceItem() { var reServiceDOPSIT models.ReService var reServiceDOSFSC models.ReService + var reServiceIOFSIT models.ReService + setupTestData := func() models.MTOShipment { move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ @@ -1090,6 +1092,56 @@ func (suite *MTOServiceItemServiceSuite) TestCreateOriginSITServiceItem() { return mtoShipment } + setupTestInternationalData := func(isOconusPickupAddress bool, isOconusDestinationAddress bool) models.MTOShipment { + oconusAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + + conusAddress := factory.BuildAddress(suite.DB(), nil, nil) + + var pickupAddress models.Address + var destinationAddress models.Address + + if isOconusPickupAddress { + pickupAddress = oconusAddress + } else { + pickupAddress = conusAddress + } + + if isOconusDestinationAddress { + destinationAddress = oconusAddress + } else { + destinationAddress = conusAddress + } + + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + PickupAddressID: &pickupAddress.ID, + DestinationAddressID: &destinationAddress.ID, + }, + }, + }, nil) + + reServiceIOFSIT = factory.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIOFSIT) + + return mtoShipment + } + sitEntryDate := time.Date(2020, time.October, 24, 0, 0, 0, 0, time.UTC) sitPostalCode := "99999" reason := "lorem ipsum" @@ -1222,6 +1274,92 @@ func (suite *MTOServiceItemServiceSuite) TestCreateOriginSITServiceItem() { suite.Equal(1, numDOSFSCFound) }) + suite.Run("Create IOFSIT service item and auto-create IOASIT, IOPSIT, IOSFSC", func() { + // TESTCASE SCENARIO + // Under test: CreateMTOServiceItem function + // Set up: Create IOFSIT service item with a new address + // Expected outcome: Success, 4 service items created + + // Customer gets new pickup address for SIT Origin Pickup (IOPSIT) which gets added when + // creating IOFSIT (SIT origin first day). + shipment := setupTestInternationalData(false, true) + + // Do not create Address in the database (Assertions.Stub = true) because if the information is coming from the Prime + // via the Prime API, the address will not have a valid database ID. And tests need to ensure + // that we properly create the address coming in from the API. + country := factory.FetchOrBuildCountry(suite.DB(), nil, nil) + actualPickupAddress := factory.BuildAddress(nil, nil, []factory.Trait{factory.GetTraitAddress2}) + actualPickupAddress.ID = uuid.Nil + actualPickupAddress.CountryId = &country.ID + actualPickupAddress.Country = &country + + serviceItemIOFSIT := models.MTOServiceItem{ + MoveTaskOrder: shipment.MoveTaskOrder, + MoveTaskOrderID: shipment.MoveTaskOrderID, + MTOShipment: shipment, + MTOShipmentID: &shipment.ID, + ReService: reServiceIOFSIT, + SITEntryDate: &sitEntryDate, + SITPostalCode: &sitPostalCode, + Reason: &reason, + SITOriginHHGActualAddress: &actualPickupAddress, + Status: models.MTOServiceItemStatusSubmitted, + } + + builder := query.NewQueryBuilder() + moveRouter := moverouter.NewMoveRouter() + planner := &mocks.Planner{} + planner.On("ZipTransitDistance", + mock.AnythingOfType("*appcontext.appContext"), + mock.Anything, + mock.Anything, + false, + ).Return(50, nil) + creator := NewMTOServiceItemCreator(planner, builder, moveRouter, ghcrateengine.NewDomesticUnpackPricer(), ghcrateengine.NewDomesticPackPricer(), ghcrateengine.NewDomesticLinehaulPricer(), ghcrateengine.NewDomesticShorthaulPricer(), ghcrateengine.NewDomesticOriginPricer(), ghcrateengine.NewDomesticDestinationPricer(), ghcrateengine.NewFuelSurchargePricer()) + + createdServiceItems, _, err := creator.CreateMTOServiceItem(suite.AppContextForTest(), &serviceItemIOFSIT) + suite.NotNil(createdServiceItems) + suite.NoError(err) + + createdServiceItemsList := *createdServiceItems + suite.Equal(4, len(createdServiceItemsList)) + + numIOFSITFound := 0 + numIOASITFound := 0 + numIOPSITFound := 0 + numIOSFSCFound := 0 + + for _, item := range createdServiceItemsList { + suite.Equal(serviceItemIOFSIT.MoveTaskOrderID, item.MoveTaskOrderID) + suite.Equal(serviceItemIOFSIT.MTOShipmentID, item.MTOShipmentID) + suite.Equal(serviceItemIOFSIT.SITEntryDate, item.SITEntryDate) + suite.Equal(serviceItemIOFSIT.Reason, item.Reason) + suite.Equal(serviceItemIOFSIT.SITPostalCode, item.SITPostalCode) + suite.Equal(actualPickupAddress.StreetAddress1, item.SITOriginHHGActualAddress.StreetAddress1) + suite.Equal(actualPickupAddress.ID, *item.SITOriginHHGActualAddressID) + + if item.ReService.Code == models.ReServiceCodeIOPSIT || item.ReService.Code == models.ReServiceCodeIOSFSC { + suite.Equal(*item.SITDeliveryMiles, 50) + } + + switch item.ReService.Code { + case models.ReServiceCodeIOFSIT: + numIOFSITFound++ + case models.ReServiceCodeIOASIT: + numIOASITFound++ + case models.ReServiceCodeIOPSIT: + numIOPSITFound++ + case models.ReServiceCodeIOSFSC: + numIOSFSCFound++ + } + } + + suite.Equal(1, numIOFSITFound) + suite.Equal(1, numIOASITFound) + suite.Equal(1, numIOPSITFound) + suite.Equal(1, numIOSFSCFound) + }) + setupDOFSIT := func(shipment models.MTOShipment) services.MTOServiceItemCreator { // Create DOFSIT country := factory.FetchOrBuildCountry(suite.DB(), nil, nil) @@ -1657,6 +1795,57 @@ func (suite *MTOServiceItemServiceSuite) TestCreateDestSITServiceItem() { } + setupTestInternationalData := func() (models.MTOShipment, services.MTOServiceItemCreator, models.ReService) { + move := factory.BuildMove(suite.DB(), []factory.Customization{ + { + Model: models.Move{ + Status: models.MoveStatusAPPROVED, + }, + }, + }, nil) + + pickupAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + + destinationAddress := factory.BuildAddress(suite.DB(), nil, nil) + + shipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + PickupAddressID: &pickupAddress.ID, + DestinationAddressID: &destinationAddress.ID, + }, + }, + }, nil) + builder := query.NewQueryBuilder() + moveRouter := moverouter.NewMoveRouter() + planner := &mocks.Planner{} + planner.On("ZipTransitDistance", + mock.AnythingOfType("*appcontext.appContext"), + mock.Anything, + mock.Anything, + false, + ).Return(125, nil) + creator := NewMTOServiceItemCreator(planner, builder, moveRouter, ghcrateengine.NewDomesticUnpackPricer(), ghcrateengine.NewDomesticPackPricer(), ghcrateengine.NewDomesticLinehaulPricer(), ghcrateengine.NewDomesticShorthaulPricer(), ghcrateengine.NewDomesticOriginPricer(), ghcrateengine.NewDomesticDestinationPricer(), ghcrateengine.NewFuelSurchargePricer()) + + reServiceIDFSIT := factory.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIDFSIT) + return shipment, creator, reServiceIDFSIT + } + setupAdditionalSIT := func() (models.ReService, models.ReService, models.ReService) { // These codes will be needed for the following tests: reServiceDDASIT := factory.FetchReServiceByCode(suite.DB(), models.ReServiceCodeDDASIT) @@ -1665,6 +1854,14 @@ func (suite *MTOServiceItemServiceSuite) TestCreateDestSITServiceItem() { return reServiceDDASIT, reServiceDDDSIT, reServiceDDSFSC } + setupAdditionalInternationalSIT := func() (models.ReService, models.ReService, models.ReService) { + // These codes will be needed for the following tests: + reServiceIDASIT := factory.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIDASIT) + reServiceIDDSIT := factory.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIDDSIT) + reServiceIDSFSC := factory.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIDSFSC) + return reServiceIDASIT, reServiceIDDSIT, reServiceIDSFSC + } + getCustomerContacts := func() models.MTOServiceItemCustomerContacts { deliveryDate := time.Now() attemptedContact := time.Now() @@ -1812,6 +2009,76 @@ func (suite *MTOServiceItemServiceSuite) TestCreateDestSITServiceItem() { suite.Equal(createdServiceItemList[2].CustomerContacts[1], serviceItemDDFSIT.CustomerContacts[1]) }) + // Successful creation of IDFSIT service item and the extra IDASIT/IDDSIT items + suite.Run("Success - IDFSIT creation approved - no SITDestinationFinalAddress", func() { + shipment, creator, reServiceIDFSIT := setupTestInternationalData() + setupAdditionalInternationalSIT() + + serviceItemIDFSIT := models.MTOServiceItem{ + MoveTaskOrderID: shipment.MoveTaskOrderID, + MoveTaskOrder: shipment.MoveTaskOrder, + MTOShipmentID: &shipment.ID, + MTOShipment: shipment, + ReService: reServiceIDFSIT, + SITEntryDate: &sitEntryDate, + SITDepartureDate: &sitDepartureDate, + CustomerContacts: getCustomerContacts(), + Status: models.MTOServiceItemStatusSubmitted, + } + + createdServiceItems, _, err := creator.CreateMTOServiceItem(suite.AppContextForTest(), &serviceItemIDFSIT) + suite.NotNil(createdServiceItems) + suite.NoError(err) + + createdServiceItemList := *createdServiceItems + suite.Equal(len(createdServiceItemList), 4) + + // check the returned items for the correct data + numIDASITFound := 0 + numIDDSITFound := 0 + numIDFSITFound := 0 + numIDSFSCFound := 0 + for _, item := range createdServiceItemList { + suite.Equal(item.MoveTaskOrderID, serviceItemIDFSIT.MoveTaskOrderID) + suite.Equal(item.MTOShipmentID, serviceItemIDFSIT.MTOShipmentID) + suite.Equal(item.SITEntryDate, serviceItemIDFSIT.SITEntryDate) + suite.Equal(item.SITDepartureDate, serviceItemIDFSIT.SITDepartureDate) + + suite.Equal(item.SITDestinationOriginalAddressID, serviceItemIDFSIT.SITDestinationOriginalAddressID) + suite.Equal(item.SITDestinationFinalAddressID, serviceItemIDFSIT.SITDestinationFinalAddressID) + + if item.ReService.Code == models.ReServiceCodeIDDSIT || item.ReService.Code == models.ReServiceCodeIDSFSC { + // if this fails check the mock in the setupdata func and/or if destination address is OCONUS + suite.Equal(*item.SITDeliveryMiles, 125) + } + + if item.ReService.Code == models.ReServiceCodeIDASIT { + numIDASITFound++ + } + if item.ReService.Code == models.ReServiceCodeIDDSIT { + numIDDSITFound++ + } + if item.ReService.Code == models.ReServiceCodeIDFSIT { + numIDFSITFound++ + suite.Equal(len(item.CustomerContacts), len(serviceItemIDFSIT.CustomerContacts)) + } + if item.ReService.Code == models.ReServiceCodeIDDSIT { + numIDSFSCFound++ + } + } + suite.Equal(numIDASITFound, 1) + suite.Equal(numIDDSITFound, 1) + suite.Equal(numIDFSITFound, 1) + suite.Equal(numIDSFSCFound, 1) + + // We create one set of customer contacts and attach them to each destination service item. + // This portion verifies that. + suite.Equal(createdServiceItemList[1].CustomerContacts[0], serviceItemIDFSIT.CustomerContacts[0]) + suite.Equal(createdServiceItemList[1].CustomerContacts[1], serviceItemIDFSIT.CustomerContacts[1]) + suite.Equal(createdServiceItemList[2].CustomerContacts[0], serviceItemIDFSIT.CustomerContacts[0]) + suite.Equal(createdServiceItemList[2].CustomerContacts[1], serviceItemIDFSIT.CustomerContacts[1]) + }) + // Failed creation of DDFSIT because of duplicate service for shipment suite.Run("Failure - duplicate DDFSIT", func() { shipment, creator, reServiceDDFSIT := setupTestData() diff --git a/pkg/services/mto_service_item/mto_service_item_updater_test.go b/pkg/services/mto_service_item/mto_service_item_updater_test.go index 27abd914a79..f311e4beb00 100644 --- a/pkg/services/mto_service_item/mto_service_item_updater_test.go +++ b/pkg/services/mto_service_item/mto_service_item_updater_test.go @@ -3170,6 +3170,7 @@ func (suite *MTOServiceItemServiceSuite) TestUpdateMTOServiceItemPricingEstimate mock.AnythingOfType("*appcontext.appContext"), mock.Anything, mock.Anything, + false, ).Return(400, nil) updater := NewMTOServiceItemUpdater(planner, builder, moveRouter, shipmentFetcher, addressCreator, portlocation.NewPortLocationFetcher(), ghcrateengine.NewDomesticUnpackPricer(), ghcrateengine.NewDomesticLinehaulPricer(), ghcrateengine.NewDomesticDestinationPricer(), ghcrateengine.NewFuelSurchargePricer()) diff --git a/pkg/services/mto_service_item/mto_service_item_validators_test.go b/pkg/services/mto_service_item/mto_service_item_validators_test.go index e53dc6738a4..5db60637cbc 100644 --- a/pkg/services/mto_service_item/mto_service_item_validators_test.go +++ b/pkg/services/mto_service_item/mto_service_item_validators_test.go @@ -1093,7 +1093,8 @@ func (suite *MTOServiceItemServiceSuite) TestUpdateMTOServiceItemData() { }, }, nil) newSITServiceItem := oldSITServiceItem - newSITServiceItem.SITDepartureDate = &later + newSITDepartureDate := later.AddDate(0, 0, 1) + newSITServiceItem.SITDepartureDate = &newSITDepartureDate serviceItemData := updateMTOServiceItemData{ updatedServiceItem: newSITServiceItem, oldServiceItem: oldSITServiceItem, @@ -1118,72 +1119,6 @@ func (suite *MTOServiceItemServiceSuite) TestUpdateMTOServiceItemData() { } }) - suite.Run("SITDepartureDate - Does not error or update shipment auth end date when set after the authorized end date - international", func() { - // Under test: checkSITDepartureDate checks that - // the SITDepartureDate is not later than the authorized end date - // Set up: Create an old and new IOPSIT and IDDSIT, with a date later than the - // shipment and try to update. - // Expected outcome: No ERROR if departure date comes after the end date. - // Shipment auth end date does not change - mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ - { - Model: models.MTOShipment{OriginSITAuthEndDate: &now, - DestinationSITAuthEndDate: &now}, - }, - }, nil) - testCases := []struct { - reServiceCode models.ReServiceCode - }{ - { - reServiceCode: models.ReServiceCodeIOPSIT, - }, - { - reServiceCode: models.ReServiceCodeIDDSIT, - }, - } - for _, tc := range testCases { - oldSITServiceItem := factory.BuildMTOServiceItem(nil, []factory.Customization{ - { - Model: models.ReService{ - Code: tc.reServiceCode, - }, - }, - { - Model: mtoShipment, - LinkOnly: true, - }, - { - Model: models.MTOServiceItem{ - SITEntryDate: &later, - }, - }, - }, nil) - newSITServiceItem := oldSITServiceItem - newSITServiceItem.SITDepartureDate = &later - serviceItemData := updateMTOServiceItemData{ - updatedServiceItem: newSITServiceItem, - oldServiceItem: oldSITServiceItem, - verrs: validate.NewErrors(), - } - err := serviceItemData.checkSITDepartureDate(suite.AppContextForTest()) - suite.NoError(err) - suite.False(serviceItemData.verrs.HasAny()) - - // Double check the shipment and ensure that the SITDepartureDate is in fact after the authorized end date - var postUpdateShipment models.MTOShipment - err = suite.DB().Find(&postUpdateShipment, mtoShipment.ID) - suite.NoError(err) - if tc.reServiceCode == models.ReServiceCodeIOPSIT { - suite.True(mtoShipment.OriginSITAuthEndDate.Truncate(24 * time.Hour).Equal(postUpdateShipment.OriginSITAuthEndDate.Truncate(24 * time.Hour))) - suite.True(newSITServiceItem.SITEntryDate.Truncate(24 * time.Hour).After(postUpdateShipment.OriginSITAuthEndDate.Truncate(24 * time.Hour))) - } - if tc.reServiceCode == models.ReServiceCodeIDDSIT { - suite.True(mtoShipment.DestinationSITAuthEndDate.Truncate(24 * time.Hour).Equal(postUpdateShipment.DestinationSITAuthEndDate.Truncate(24 * time.Hour))) - suite.True(newSITServiceItem.SITEntryDate.Truncate(24 * time.Hour).After(postUpdateShipment.DestinationSITAuthEndDate.Truncate(24 * time.Hour))) - } - } - }) - suite.Run("SITDepartureDate - errors when set before or equal the SIT entry date", func() { mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ { @@ -1428,7 +1363,7 @@ func (suite *MTOServiceItemServiceSuite) TestUpdateMTOServiceItemData() { suite.NoError(err) // Just verrs suite.True(serviceItemData.verrs.HasAny()) suite.Contains(serviceItemData.verrs.Keys(), "SITDepartureDate") - suite.Contains(serviceItemData.verrs.Get("SITDepartureDate"), "SIT departure date cannot be set before the SIT entry date.") + suite.Contains(serviceItemData.verrs.Get("SITDepartureDate"), "SIT departure date cannot be set before or equal to the SIT entry date.") } }) From 541b58d39d3d2d0a9ef61606cd961e2dd67e2769 Mon Sep 17 00:00:00 2001 From: Michael Inthavongsay Date: Wed, 5 Mar 2025 17:06:46 +0000 Subject: [PATCH 3/5] Fix merge issue and cherry picked Maria's changes --- .../mto_service_item_validators_test.go | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/pkg/services/mto_service_item/mto_service_item_validators_test.go b/pkg/services/mto_service_item/mto_service_item_validators_test.go index 5db60637cbc..da734163729 100644 --- a/pkg/services/mto_service_item/mto_service_item_validators_test.go +++ b/pkg/services/mto_service_item/mto_service_item_validators_test.go @@ -1093,8 +1093,7 @@ func (suite *MTOServiceItemServiceSuite) TestUpdateMTOServiceItemData() { }, }, nil) newSITServiceItem := oldSITServiceItem - newSITDepartureDate := later.AddDate(0, 0, 1) - newSITServiceItem.SITDepartureDate = &newSITDepartureDate + newSITServiceItem.SITDepartureDate = &later serviceItemData := updateMTOServiceItemData{ updatedServiceItem: newSITServiceItem, oldServiceItem: oldSITServiceItem, @@ -1119,6 +1118,75 @@ func (suite *MTOServiceItemServiceSuite) TestUpdateMTOServiceItemData() { } }) + suite.Run("SITDepartureDate - Does not error or update shipment auth end date when set after the authorized end date - international", func() { + // Under test: checkSITDepartureDate checks that + // the SITDepartureDate is not later than the authorized end date + // Set up: Create an old and new IOPSIT and IDDSIT, with a date later than the + // shipment and try to update. + // Expected outcome: No ERROR if departure date comes after the end date. + // Shipment auth end date does not change + mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{OriginSITAuthEndDate: &now, + DestinationSITAuthEndDate: &now}, + }, + }, nil) + testCases := []struct { + reServiceCode models.ReServiceCode + }{ + { + reServiceCode: models.ReServiceCodeIOPSIT, + }, + { + reServiceCode: models.ReServiceCodeIDDSIT, + }, + } + now := time.Now() + nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + later := nowDate.AddDate(0, 0, 3) + for _, tc := range testCases { + oldSITServiceItem := factory.BuildMTOServiceItem(nil, []factory.Customization{ + { + Model: models.ReService{ + Code: tc.reServiceCode, + }, + }, + { + Model: mtoShipment, + LinkOnly: true, + }, + { + Model: models.MTOServiceItem{ + SITEntryDate: &nowDate, + }, + }, + }, nil) + newSITServiceItem := oldSITServiceItem + newSITServiceItem.SITDepartureDate = &later + serviceItemData := updateMTOServiceItemData{ + updatedServiceItem: newSITServiceItem, + oldServiceItem: oldSITServiceItem, + verrs: validate.NewErrors(), + } + err := serviceItemData.checkSITDepartureDate(suite.AppContextForTest()) + suite.NoError(err) + suite.False(serviceItemData.verrs.HasAny()) + + // Double check the shipment and ensure that the SITDepartureDate is in fact after the authorized end date + var postUpdateShipment models.MTOShipment + err = suite.DB().Find(&postUpdateShipment, mtoShipment.ID) + suite.NoError(err) + if tc.reServiceCode == models.ReServiceCodeIOPSIT { + suite.True(mtoShipment.OriginSITAuthEndDate.Truncate(24 * time.Hour).Equal(postUpdateShipment.OriginSITAuthEndDate.Truncate(24 * time.Hour))) + suite.True(newSITServiceItem.SITDepartureDate.Truncate(24 * time.Hour).After(postUpdateShipment.OriginSITAuthEndDate.Truncate(24 * time.Hour))) + } + if tc.reServiceCode == models.ReServiceCodeIDDSIT { + suite.True(mtoShipment.DestinationSITAuthEndDate.Truncate(24 * time.Hour).Equal(postUpdateShipment.DestinationSITAuthEndDate.Truncate(24 * time.Hour))) + suite.True(newSITServiceItem.SITDepartureDate.Truncate(24 * time.Hour).After(postUpdateShipment.DestinationSITAuthEndDate.Truncate(24 * time.Hour))) + } + } + }) + suite.Run("SITDepartureDate - errors when set before or equal the SIT entry date", func() { mtoShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ { From 2aace62d180e9ecb5b6e7ac6cd4175b6b445f114 Mon Sep 17 00:00:00 2001 From: Michael Inthavongsay Date: Wed, 5 Mar 2025 19:59:13 +0000 Subject: [PATCH 4/5] generate mocks --- ...tionalDestinationSITFuelSurchargePricer.go | 109 ++++++++++++++++++ ...ternationalOriginSITFuelSurchargePricer.go | 109 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 pkg/services/mocks/InternationalDestinationSITFuelSurchargePricer.go create mode 100644 pkg/services/mocks/InternationalOriginSITFuelSurchargePricer.go diff --git a/pkg/services/mocks/InternationalDestinationSITFuelSurchargePricer.go b/pkg/services/mocks/InternationalDestinationSITFuelSurchargePricer.go new file mode 100644 index 00000000000..1081c3fa9cc --- /dev/null +++ b/pkg/services/mocks/InternationalDestinationSITFuelSurchargePricer.go @@ -0,0 +1,109 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + appcontext "github.com/transcom/mymove/pkg/appcontext" + + models "github.com/transcom/mymove/pkg/models" + + services "github.com/transcom/mymove/pkg/services" + + time "time" + + unit "github.com/transcom/mymove/pkg/unit" +) + +// InternationalDestinationSITFuelSurchargePricer is an autogenerated mock type for the InternationalDestinationSITFuelSurchargePricer type +type InternationalDestinationSITFuelSurchargePricer struct { + mock.Mock +} + +// Price provides a mock function with given fields: appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM +func (_m *InternationalDestinationSITFuelSurchargePricer) Price(appCtx appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + + if len(ret) == 0 { + panic("no return value specified for Price") + } + + var r0 unit.Cents + var r1 services.PricingDisplayParams + var r2 error + if rf, ok := ret.Get(0).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) unit.Cents); ok { + r0 = rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) services.PricingDisplayParams); ok { + r1 = rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) error); ok { + r2 = rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// PriceUsingParams provides a mock function with given fields: appCtx, params +func (_m *InternationalDestinationSITFuelSurchargePricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, params) + + if len(ret) == 0 { + panic("no return value specified for PriceUsingParams") + } + + var r0 unit.Cents + var r1 services.PricingDisplayParams + var r2 error + if rf, ok := ret.Get(0).(func(appcontext.AppContext, models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, params) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, models.PaymentServiceItemParams) unit.Cents); ok { + r0 = rf(appCtx, params) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, models.PaymentServiceItemParams) services.PricingDisplayParams); ok { + r1 = rf(appCtx, params) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, models.PaymentServiceItemParams) error); ok { + r2 = rf(appCtx, params) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewInternationalDestinationSITFuelSurchargePricer creates a new instance of InternationalDestinationSITFuelSurchargePricer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewInternationalDestinationSITFuelSurchargePricer(t interface { + mock.TestingT + Cleanup(func()) +}) *InternationalDestinationSITFuelSurchargePricer { + mock := &InternationalDestinationSITFuelSurchargePricer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/mocks/InternationalOriginSITFuelSurchargePricer.go b/pkg/services/mocks/InternationalOriginSITFuelSurchargePricer.go new file mode 100644 index 00000000000..05577d07881 --- /dev/null +++ b/pkg/services/mocks/InternationalOriginSITFuelSurchargePricer.go @@ -0,0 +1,109 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + appcontext "github.com/transcom/mymove/pkg/appcontext" + + models "github.com/transcom/mymove/pkg/models" + + services "github.com/transcom/mymove/pkg/services" + + time "time" + + unit "github.com/transcom/mymove/pkg/unit" +) + +// InternationalOriginSITFuelSurchargePricer is an autogenerated mock type for the InternationalOriginSITFuelSurchargePricer type +type InternationalOriginSITFuelSurchargePricer struct { + mock.Mock +} + +// Price provides a mock function with given fields: appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM +func (_m *InternationalOriginSITFuelSurchargePricer) Price(appCtx appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents, isPPM bool) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + + if len(ret) == 0 { + panic("no return value specified for Price") + } + + var r0 unit.Cents + var r1 services.PricingDisplayParams + var r2 error + if rf, ok := ret.Get(0).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) unit.Cents); ok { + r0 = rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) services.PricingDisplayParams); ok { + r1 = rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, time.Time, unit.Miles, unit.Pound, float64, unit.Millicents, bool) error); ok { + r2 = rf(appCtx, actualPickupDate, distance, weight, fscWeightBasedDistanceMultiplier, eiaFuelPrice, isPPM) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// PriceUsingParams provides a mock function with given fields: appCtx, params +func (_m *InternationalOriginSITFuelSurchargePricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, params) + + if len(ret) == 0 { + panic("no return value specified for PriceUsingParams") + } + + var r0 unit.Cents + var r1 services.PricingDisplayParams + var r2 error + if rf, ok := ret.Get(0).(func(appcontext.AppContext, models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, params) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, models.PaymentServiceItemParams) unit.Cents); ok { + r0 = rf(appCtx, params) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, models.PaymentServiceItemParams) services.PricingDisplayParams); ok { + r1 = rf(appCtx, params) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, models.PaymentServiceItemParams) error); ok { + r2 = rf(appCtx, params) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewInternationalOriginSITFuelSurchargePricer creates a new instance of InternationalOriginSITFuelSurchargePricer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewInternationalOriginSITFuelSurchargePricer(t interface { + mock.TestingT + Cleanup(func()) +}) *InternationalOriginSITFuelSurchargePricer { + mock := &InternationalOriginSITFuelSurchargePricer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} From 391428f6d858f81026727e856d0c42243658f636 Mon Sep 17 00:00:00 2001 From: Michael Inthavongsay Date: Thu, 6 Mar 2025 15:24:59 +0000 Subject: [PATCH 5/5] remove debugging printout --- .../service_param_value_lookups/service_param_value_lookups.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go b/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go index c4781646826..6564964e677 100644 --- a/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go +++ b/pkg/payment_request/service_param_value_lookups/service_param_value_lookups.go @@ -243,9 +243,6 @@ func ServiceParamLookupInitialize( paramKeyLookups := InitializeLookups(appCtx, mtoShipment, mtoServiceItem) for _, paramKeyName := range ServiceItemParamsWithLookups { - if paramKeyName == "ZipSITOriginHHGActualAddress" { - println(paramKeyName) - } lookup, ok := paramKeyLookups[paramKeyName] if !ok { return nil, fmt.Errorf("no lookup was found for service item param key name %s", paramKeyName)