diff --git a/migrations/app/migrations_manifest.txt b/migrations/app/migrations_manifest.txt index 8b547c35245..5c8361c8d4f 100644 --- a/migrations/app/migrations_manifest.txt +++ b/migrations/app/migrations_manifest.txt @@ -1078,6 +1078,7 @@ 20250113152050_rename_ubp.up.sql 20250113160816_updating_create_accessorial_service_item_proc.up.sql 20250113201232_update_estimated_pricing_procs_add_is_peak_func.up.sql +20250114164752_add_ppm_estimated_incentive_proc.up.sql 20250116200912_disable_homesafe_stg_cert.up.sql 20250120144247_update_pricing_proc_to_use_110_percent_weight.up.sql 20250121153007_update_pricing_proc_to_handle_international_shuttle.up.sql diff --git a/migrations/app/schema/20250114164752_add_ppm_estimated_incentive_proc.up.sql b/migrations/app/schema/20250114164752_add_ppm_estimated_incentive_proc.up.sql new file mode 100644 index 00000000000..c0d7b3c9407 --- /dev/null +++ b/migrations/app/schema/20250114164752_add_ppm_estimated_incentive_proc.up.sql @@ -0,0 +1,249 @@ +-- IDFSIT PerUnitCents +INSERT INTO service_params (id,service_id,service_item_param_key_id,created_at,updated_at,is_optional) VALUES + ('fb7925e7-ebfe-49d9-9cf4-7219e68ec686'::uuid,'bd6064ca-e780-4ab4-a37b-0ae98eebb244','597bb77e-0ce7-4ba2-9624-24300962625f','2024-01-17 15:55:50.041957','2024-01-17 15:55:50.041957',false); -- PerUnitCents + +-- IDASIT PerUnitCents +INSERT INTO service_params (id,service_id,service_item_param_key_id,created_at,updated_at,is_optional) VALUES + ('51393ee1-f505-4f7b-96c4-135f771af814'::uuid,'806c6d59-57ff-4a3f-9518-ebf29ba9cb10','597bb77e-0ce7-4ba2-9624-24300962625f','2024-01-17 15:55:50.041957','2024-01-17 15:55:50.041957',false); -- PerUnitCents + +-- IOFSIT PerUnitCents +INSERT INTO service_params (id,service_id,service_item_param_key_id,created_at,updated_at,is_optional) VALUES + ('7518ec84-0c40-4c17-86dd-3ce04e2fe701'::uuid,'b488bf85-ea5e-49c8-ba5c-e2fa278ac806','597bb77e-0ce7-4ba2-9624-24300962625f','2024-01-17 15:55:50.041957','2024-01-17 15:55:50.041957',false); -- PerUnitCents + +-- IOASIT PerUnitCents +INSERT INTO service_params (id,service_id,service_item_param_key_id,created_at,updated_at,is_optional) VALUES + ('cff34123-e2a5-40ed-9cf3-451701850a26'::uuid,'bd424e45-397b-4766-9712-de4ae3a2da36','597bb77e-0ce7-4ba2-9624-24300962625f','2024-01-17 15:55:50.041957','2024-01-17 15:55:50.041957',false); -- PerUnitCents + +-- inserting PortZip param for FSC +-- we need this for international PPMs since they only get reimbursed for the CONUS -> Port portion +INSERT INTO service_params (id,service_id,service_item_param_key_id,created_at,updated_at,is_optional) VALUES + ('bb53e034-80c2-420e-8492-f54d2018fff1'::uuid,'4780b30c-e846-437a-b39a-c499a6b09872','d9ad3878-4b94-4722-bbaf-d4b8080f339d','2024-01-17 15:55:50.041957','2024-01-17 15:55:50.041957',true); -- PortZip + +-- remove PriceAreaIntlOrigin, we don't need it +DELETE FROM service_params +WHERE service_item_param_key_id = '6d44624c-b91b-4226-8fcd-98046e2f433d'; + +DELETE FROM service_item_param_keys +WHERE key = 'PriceAreaIntlOrigin'; + +-- remove PriceAreaIntlDest, we don't need it +DELETE FROM service_params +WHERE service_item_param_key_id = '4736f489-dfda-4df1-a303-8c434a120d5d'; + +DELETE FROM service_item_param_keys +WHERE key = 'PriceAreaIntlDest'; + +-- adding port info that PPMs will consume +INSERT INTO public.ports +(id, port_code, port_type, port_name, created_at, updated_at) +VALUES('d8776c6b-bc5e-45d8-ac50-ab60c34c022d'::uuid, '4E1', 'S','TACOMA, PUGET SOUND', now(), now()); + +INSERT INTO public.port_locations +(id, port_id, cities_id, us_post_region_cities_id, country_id, is_active, created_at, updated_at) +VALUES('ee3a97dc-112e-4805-8518-f56f2d9c6cc6'::uuid, 'd8776c6b-bc5e-45d8-ac50-ab60c34c022d'::uuid, 'baaf6ab1-6142-4fb7-b753-d0a142c75baf'::uuid, '86fef297-d61f-44ea-afec-4f679ce686b7'::uuid, '791899e6-cd77-46f2-981b-176ecb8d7098'::uuid, true, now(), now()); + +-- func to fetch a service id from re_services by providing the service code +CREATE OR REPLACE FUNCTION get_service_id(service_code TEXT) RETURNS UUID AS $$ +DECLARE + service_id UUID; +BEGIN + SELECT rs.id INTO service_id FROM re_services rs WHERE rs.code = service_code; + IF service_id IS NULL THEN + RAISE EXCEPTION 'Service code % not found in re_services', service_code; + END IF; + RETURN service_id; +END; +$$ LANGUAGE plpgsql; + + +-- db func that will calculate a PPM's incentives +-- this is used for estimated/final/max incentives +CREATE OR REPLACE FUNCTION calculate_ppm_incentive( + ppm_id UUID, + pickup_address_id UUID, + destination_address_id UUID, + move_date DATE, + mileage INT, + weight INT, + is_estimated BOOLEAN, + is_actual BOOLEAN, + is_max BOOLEAN +) RETURNS TABLE ( + total_incentive NUMERIC, + price_islh NUMERIC, + price_ihpk NUMERIC, + price_ihupk NUMERIC, + price_fsc NUMERIC +) AS +$$ +DECLARE + ppm RECORD; + contract_id UUID; + o_rate_area_id UUID; + d_rate_area_id UUID; + service_id UUID; + estimated_fsc_multiplier NUMERIC; + fuel_price NUMERIC; + price_difference NUMERIC; + cents_above_baseline NUMERIC; +BEGIN + + IF NOT is_estimated AND NOT is_actual AND NOT is_max THEN + RAISE EXCEPTION 'is_estimated, is_actual, and is_max cannot all be FALSE. No update will be performed.'; + END IF; + + -- validating it's a real PPM + SELECT ppms.id INTO ppm FROM ppm_shipments ppms WHERE ppms.id = ppm_id; + IF ppm IS NULL THEN + RAISE EXCEPTION 'PPM with ID % not found', ppm_id; + END IF; + + contract_id := get_contract_id(move_date); + IF contract_id IS NULL THEN + RAISE EXCEPTION 'Contract not found for date: %', move_date; + END IF; + + o_rate_area_id := get_rate_area_id(pickup_address_id, NULL, contract_id); + IF o_rate_area_id IS NULL THEN + RAISE EXCEPTION 'Origin rate area is NULL for address ID %', pickup_address_id; + END IF; + + d_rate_area_id := get_rate_area_id(destination_address_id, NULL, contract_id); + IF d_rate_area_id IS NULL THEN + RAISE EXCEPTION 'Destination rate area is NULL for address ID %', destination_address_id; + END IF; + + -- ISLH calculation + service_id := get_service_id('ISLH'); + price_islh := ROUND( + calculate_escalated_price( + o_rate_area_id, + d_rate_area_id, + service_id, + contract_id, + 'ISLH', + move_date + ) * (weight / 100)::NUMERIC * 100, 0 + ); + + -- IHPK calculation + service_id := get_service_id('IHPK'); + price_ihpk := ROUND( + calculate_escalated_price( + o_rate_area_id, + NULL, + service_id, + contract_id, + 'IHPK', + move_date + ) * (weight / 100)::NUMERIC * 100, 0 + ); + + -- IHUPK calculation + service_id := get_service_id('IHUPK'); + price_ihupk := ROUND( + calculate_escalated_price( + NULL, + d_rate_area_id, + service_id, + contract_id, + 'IHUPK', + move_date + ) * (weight / 100)::NUMERIC * 100, 0 + ); + + -- FSC calculation + estimated_fsc_multiplier := get_fsc_multiplier(weight); + fuel_price := get_fuel_price(move_date); + price_difference := calculate_price_difference(fuel_price); + cents_above_baseline := mileage * estimated_fsc_multiplier; + price_fsc := ROUND((cents_above_baseline * price_difference) * 100); + + total_incentive := price_islh + price_ihpk + price_ihupk + price_fsc; + + UPDATE ppm_shipments + SET estimated_incentive = CASE WHEN is_estimated THEN total_incentive ELSE estimated_incentive END, + final_incentive = CASE WHEN is_actual THEN total_incentive ELSE final_incentive END, + max_incentive = CASE WHEN is_max THEN total_incentive ELSE max_incentive END + WHERE id = ppm_id; + + -- returning a table so we can use this data in the breakdown for the service member + RETURN QUERY SELECT total_incentive, price_islh, price_ihpk, price_ihupk, price_fsc; +END; +$$ LANGUAGE plpgsql; + + +-- db func that will calculate a PPM's SIT cost +-- returns a table with total cost and the cost of each first day/add'l day SIT service item +CREATE OR REPLACE FUNCTION calculate_ppm_sit_cost( + ppm_id UUID, + address_id UUID, + is_origin BOOLEAN, + move_date DATE, + weight INT, + sit_days INT +) RETURNS TABLE ( + total_cost INT, + price_first_day INT, + price_addl_day INT +) AS +$$ +DECLARE + ppm RECORD; + contract_id UUID; + sit_rate_area_id UUID; + service_id UUID; +BEGIN + -- make sure we validate parameters + IF sit_days IS NULL OR sit_days < 0 THEN + RAISE EXCEPTION 'SIT days must be a positive integer. Provided value: %', sit_days; + END IF; + + SELECT ppms.id INTO ppm FROM ppm_shipments ppms WHERE ppms.id = ppm_id; + IF ppm IS NULL THEN + RAISE EXCEPTION 'PPM with ID % not found', ppm_id; + END IF; + + contract_id := get_contract_id(move_date); + IF contract_id IS NULL THEN + RAISE EXCEPTION 'Contract not found for date: %', move_date; + END IF; + + sit_rate_area_id := get_rate_area_id(address_id, NULL, contract_id); + IF sit_rate_area_id IS NULL THEN + RAISE EXCEPTION 'Rate area is NULL for address ID % and contract ID %', address_id, contract_id; + END IF; + + -- calculate first day SIT cost + service_id := get_service_id(CASE WHEN is_origin THEN 'IOFSIT' ELSE 'IDFSIT' END); + price_first_day := ( + calculate_escalated_price( + CASE WHEN is_origin THEN sit_rate_area_id ELSE NULL END, + CASE WHEN NOT is_origin THEN sit_rate_area_id ELSE NULL END, + service_id, + contract_id, + CASE WHEN is_origin THEN 'IOFSIT' ELSE 'IDFSIT' END, + move_date + ) * (weight / 100)::NUMERIC * 100 + )::INT; + + -- calculate additional day SIT cost + service_id := get_service_id(CASE WHEN is_origin THEN 'IOASIT' ELSE 'IDASIT' END); + price_addl_day := ( + calculate_escalated_price( + CASE WHEN is_origin THEN sit_rate_area_id ELSE NULL END, + CASE WHEN NOT is_origin THEN sit_rate_area_id ELSE NULL END, + service_id, + contract_id, + CASE WHEN is_origin THEN 'IOASIT' ELSE 'IDASIT' END, + move_date + ) * (weight / 100)::NUMERIC * 100 * sit_days + )::INT; + + -- add em up + total_cost := price_first_day + price_addl_day; + + RETURN QUERY SELECT total_cost, price_first_day, price_addl_day; +END; +$$ LANGUAGE plpgsql; + diff --git a/pkg/factory/ppm_shipment_factory.go b/pkg/factory/ppm_shipment_factory.go index 0306aebe547..cc87032285e 100644 --- a/pkg/factory/ppm_shipment_factory.go +++ b/pkg/factory/ppm_shipment_factory.go @@ -87,42 +87,60 @@ func buildPPMShipmentWithBuildType(db *pop.Connection, customs []Customization, ppmShipment.W2Address = &w2AddressResult } - oldDutyLocationAddress := ppmShipment.Shipment.MoveTaskOrder.Orders.OriginDutyLocation.Address - pickupAddress := BuildAddress(db, []Customization{ - { - Model: models.Address{ - StreetAddress1: "987 New Street", - City: oldDutyLocationAddress.City, - State: oldDutyLocationAddress.State, - PostalCode: oldDutyLocationAddress.PostalCode, + pickupAddressResult := findValidCustomization(customs, Addresses.PickupAddress) + if pickupAddressResult != nil { + pickupAddressResultCustoms := convertCustomizationInList(customs, Addresses.PickupAddress, Address) + + pickupAddressResult := BuildAddress(db, pickupAddressResultCustoms, traits) + ppmShipment.PickupAddressID = &pickupAddressResult.ID + ppmShipment.PickupAddress = &pickupAddressResult + } else { + oldDutyLocationAddress := ppmShipment.Shipment.MoveTaskOrder.Orders.OriginDutyLocation.Address + pickupAddress := BuildAddress(db, []Customization{ + { + Model: models.Address{ + StreetAddress1: "987 New Street", + City: oldDutyLocationAddress.City, + State: oldDutyLocationAddress.State, + PostalCode: oldDutyLocationAddress.PostalCode, + }, }, - }, - }, nil) - ppmShipment.PickupAddressID = &pickupAddress.ID - ppmShipment.PickupAddress = &pickupAddress + }, nil) + ppmShipment.PickupAddressID = &pickupAddress.ID + ppmShipment.PickupAddress = &pickupAddress + } - newDutyLocationAddress := ppmShipment.Shipment.MoveTaskOrder.Orders.NewDutyLocation.Address - destinationAddress := BuildAddress(db, []Customization{ - { - Model: models.Address{ - StreetAddress1: "123 New Street", - City: newDutyLocationAddress.City, - State: newDutyLocationAddress.State, - PostalCode: newDutyLocationAddress.PostalCode, + deliveryAddressResult := findValidCustomization(customs, Addresses.DeliveryAddress) + if deliveryAddressResult != nil { + deliveryAddressResultCustoms := convertCustomizationInList(customs, Addresses.DeliveryAddress, Address) + + deliveryAddressResult := BuildAddress(db, deliveryAddressResultCustoms, traits) + ppmShipment.DestinationAddressID = &deliveryAddressResult.ID + ppmShipment.DestinationAddress = &deliveryAddressResult + } else { + newDutyLocationAddress := ppmShipment.Shipment.MoveTaskOrder.Orders.NewDutyLocation.Address + destinationAddress := BuildAddress(db, []Customization{ + { + Model: models.Address{ + StreetAddress1: "123 New Street", + City: newDutyLocationAddress.City, + State: newDutyLocationAddress.State, + PostalCode: newDutyLocationAddress.PostalCode, + }, }, - }, - }, nil) - ppmShipment.DestinationAddressID = &destinationAddress.ID - ppmShipment.DestinationAddress = &destinationAddress + }, nil) + ppmShipment.DestinationAddressID = &destinationAddress.ID + ppmShipment.DestinationAddress = &destinationAddress + } if buildType == ppmBuildFullAddress { secondaryPickupAddress := BuildAddress(db, []Customization{ { Model: models.Address{ StreetAddress1: "123 Main Street", - City: pickupAddress.City, - State: pickupAddress.State, - PostalCode: pickupAddress.PostalCode, + City: ppmShipment.PickupAddress.City, + State: ppmShipment.PickupAddress.State, + PostalCode: ppmShipment.PickupAddress.PostalCode, }, }, }, nil) @@ -130,9 +148,9 @@ func buildPPMShipmentWithBuildType(db *pop.Connection, customs []Customization, { Model: models.Address{ StreetAddress1: "1234 Main Street", - City: destinationAddress.City, - State: destinationAddress.State, - PostalCode: destinationAddress.PostalCode, + City: ppmShipment.DestinationAddress.City, + State: ppmShipment.DestinationAddress.State, + PostalCode: ppmShipment.DestinationAddress.PostalCode, }, }, }, nil) @@ -140,9 +158,9 @@ func buildPPMShipmentWithBuildType(db *pop.Connection, customs []Customization, { Model: models.Address{ StreetAddress1: "123 Third Street", - City: pickupAddress.City, - State: pickupAddress.State, - PostalCode: pickupAddress.PostalCode, + City: ppmShipment.PickupAddress.City, + State: ppmShipment.PickupAddress.State, + PostalCode: ppmShipment.PickupAddress.PostalCode, }, }, }, nil) @@ -150,9 +168,9 @@ func buildPPMShipmentWithBuildType(db *pop.Connection, customs []Customization, { Model: models.Address{ StreetAddress1: "1234 Third Street", - City: destinationAddress.City, - State: destinationAddress.State, - PostalCode: destinationAddress.PostalCode, + City: ppmShipment.DestinationAddress.City, + State: ppmShipment.DestinationAddress.State, + PostalCode: ppmShipment.DestinationAddress.PostalCode, }, }, }, nil) diff --git a/pkg/gen/ghcapi/embedded_spec.go b/pkg/gen/ghcapi/embedded_spec.go index d12300d9fad..858fa60c819 100644 --- a/pkg/gen/ghcapi/embedded_spec.go +++ b/pkg/gen/ghcapi/embedded_spec.go @@ -11350,6 +11350,27 @@ func init() { "readOnly": true, "example": "1f2270c7-7166-40ae-981e-b200ebdf3054" }, + "intlLinehaulPrice": { + "description": "The full price of international shipping and linehaul (ISLH)", + "type": "integer", + "format": "cents", + "x-nullable": true, + "x-omitempty": false + }, + "intlPackPrice": { + "description": "The full price of international packing (IHPK)", + "type": "integer", + "format": "cents", + "x-nullable": true, + "x-omitempty": false + }, + "intlUnpackPrice": { + "description": "The full price of international unpacking (IHUPK)", + "type": "integer", + "format": "cents", + "x-nullable": true, + "x-omitempty": false + }, "miles": { "description": "The distance between the old address and the new address in miles.", "type": "integer", @@ -28402,6 +28423,27 @@ func init() { "readOnly": true, "example": "1f2270c7-7166-40ae-981e-b200ebdf3054" }, + "intlLinehaulPrice": { + "description": "The full price of international shipping and linehaul (ISLH)", + "type": "integer", + "format": "cents", + "x-nullable": true, + "x-omitempty": false + }, + "intlPackPrice": { + "description": "The full price of international packing (IHPK)", + "type": "integer", + "format": "cents", + "x-nullable": true, + "x-omitempty": false + }, + "intlUnpackPrice": { + "description": "The full price of international unpacking (IHUPK)", + "type": "integer", + "format": "cents", + "x-nullable": true, + "x-omitempty": false + }, "miles": { "description": "The distance between the old address and the new address in miles.", "type": "integer", diff --git a/pkg/gen/ghcmessages/p_p_m_closeout.go b/pkg/gen/ghcmessages/p_p_m_closeout.go index b0f423ba61a..a84e0e4c2e0 100644 --- a/pkg/gen/ghcmessages/p_p_m_closeout.go +++ b/pkg/gen/ghcmessages/p_p_m_closeout.go @@ -69,6 +69,15 @@ type PPMCloseout struct { // Format: uuid ID strfmt.UUID `json:"id"` + // The full price of international shipping and linehaul (ISLH) + IntlLinehaulPrice *int64 `json:"intlLinehaulPrice"` + + // The full price of international packing (IHPK) + IntlPackPrice *int64 `json:"intlPackPrice"` + + // The full price of international unpacking (IHUPK) + IntlUnpackPrice *int64 `json:"intlUnpackPrice"` + // The distance between the old address and the new address in miles. // Example: 54 // Minimum: 0 diff --git a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go index 74ebbc80eca..881ce780a4d 100644 --- a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go +++ b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go @@ -1300,13 +1300,16 @@ func PPMCloseout(ppmCloseout *models.PPMCloseout) *ghcmessages.PPMCloseout { Gcc: handlers.FmtCost(ppmCloseout.GCC), Aoa: handlers.FmtCost(ppmCloseout.AOA), RemainingIncentive: handlers.FmtCost(ppmCloseout.RemainingIncentive), - HaulType: (*string)(&ppmCloseout.HaulType), + HaulType: (*string)(ppmCloseout.HaulType), HaulPrice: handlers.FmtCost(ppmCloseout.HaulPrice), HaulFSC: handlers.FmtCost(ppmCloseout.HaulFSC), Dop: handlers.FmtCost(ppmCloseout.DOP), Ddp: handlers.FmtCost(ppmCloseout.DDP), PackPrice: handlers.FmtCost(ppmCloseout.PackPrice), UnpackPrice: handlers.FmtCost(ppmCloseout.UnpackPrice), + IntlPackPrice: handlers.FmtCost((ppmCloseout.IntlPackPrice)), + IntlUnpackPrice: handlers.FmtCost((ppmCloseout.IntlUnpackPrice)), + IntlLinehaulPrice: handlers.FmtCost((ppmCloseout.IntlLinehaulPrice)), SITReimbursement: handlers.FmtCost(ppmCloseout.SITReimbursement), } diff --git a/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go b/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go index 5c883654ecf..7a4edc2d84d 100644 --- a/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go +++ b/pkg/handlers/ghcapi/internal/payloads/model_to_payload_test.go @@ -1681,6 +1681,83 @@ func (suite *PayloadsSuite) TestMTOShipment_POE_POD_Locations() { }) } +func (suite *PayloadsSuite) TestPPMCloseout() { + plannedMoveDate := time.Now() + actualMoveDate := time.Now() + miles := 1200 + estimatedWeight := unit.Pound(5000) + actualWeight := unit.Pound(5200) + proGearWeightCustomer := unit.Pound(300) + proGearWeightSpouse := unit.Pound(100) + grossIncentive := unit.Cents(100000) + gcc := unit.Cents(50000) + aoa := unit.Cents(20000) + remainingIncentive := unit.Cents(30000) + haulType := "Linehaul" + haulPrice := unit.Cents(40000) + haulFSC := unit.Cents(5000) + dop := unit.Cents(10000) + ddp := unit.Cents(8000) + packPrice := unit.Cents(7000) + unpackPrice := unit.Cents(6000) + intlPackPrice := unit.Cents(15000) + intlUnpackPrice := unit.Cents(14000) + intlLinehaulPrice := unit.Cents(13000) + sitReimbursement := unit.Cents(12000) + + ppmCloseout := models.PPMCloseout{ + ID: models.UUIDPointer(uuid.Must(uuid.NewV4())), + PlannedMoveDate: &plannedMoveDate, + ActualMoveDate: &actualMoveDate, + Miles: &miles, + EstimatedWeight: &estimatedWeight, + ActualWeight: &actualWeight, + ProGearWeightCustomer: &proGearWeightCustomer, + ProGearWeightSpouse: &proGearWeightSpouse, + GrossIncentive: &grossIncentive, + GCC: &gcc, + AOA: &aoa, + RemainingIncentive: &remainingIncentive, + HaulType: (*models.HaulType)(&haulType), + HaulPrice: &haulPrice, + HaulFSC: &haulFSC, + DOP: &dop, + DDP: &ddp, + PackPrice: &packPrice, + UnpackPrice: &unpackPrice, + IntlPackPrice: &intlPackPrice, + IntlUnpackPrice: &intlUnpackPrice, + IntlLinehaulPrice: &intlLinehaulPrice, + SITReimbursement: &sitReimbursement, + } + + payload := PPMCloseout(&ppmCloseout) + suite.NotNil(payload) + suite.Equal(ppmCloseout.ID.String(), payload.ID.String()) + suite.Equal(handlers.FmtDatePtr(ppmCloseout.PlannedMoveDate), payload.PlannedMoveDate) + suite.Equal(handlers.FmtDatePtr(ppmCloseout.ActualMoveDate), payload.ActualMoveDate) + suite.Equal(handlers.FmtIntPtrToInt64(ppmCloseout.Miles), payload.Miles) + suite.Equal(handlers.FmtPoundPtr(ppmCloseout.EstimatedWeight), payload.EstimatedWeight) + suite.Equal(handlers.FmtPoundPtr(ppmCloseout.ActualWeight), payload.ActualWeight) + suite.Equal(handlers.FmtPoundPtr(ppmCloseout.ProGearWeightCustomer), payload.ProGearWeightCustomer) + suite.Equal(handlers.FmtPoundPtr(ppmCloseout.ProGearWeightSpouse), payload.ProGearWeightSpouse) + suite.Equal(handlers.FmtCost(ppmCloseout.GrossIncentive), payload.GrossIncentive) + suite.Equal(handlers.FmtCost(ppmCloseout.GCC), payload.Gcc) + suite.Equal(handlers.FmtCost(ppmCloseout.AOA), payload.Aoa) + suite.Equal(handlers.FmtCost(ppmCloseout.RemainingIncentive), payload.RemainingIncentive) + suite.Equal((*string)(ppmCloseout.HaulType), payload.HaulType) + suite.Equal(handlers.FmtCost(ppmCloseout.HaulPrice), payload.HaulPrice) + suite.Equal(handlers.FmtCost(ppmCloseout.HaulFSC), payload.HaulFSC) + suite.Equal(handlers.FmtCost(ppmCloseout.DOP), payload.Dop) + suite.Equal(handlers.FmtCost(ppmCloseout.DDP), payload.Ddp) + suite.Equal(handlers.FmtCost(ppmCloseout.PackPrice), payload.PackPrice) + suite.Equal(handlers.FmtCost(ppmCloseout.UnpackPrice), payload.UnpackPrice) + suite.Equal(handlers.FmtCost(ppmCloseout.IntlPackPrice), payload.IntlPackPrice) + suite.Equal(handlers.FmtCost(ppmCloseout.IntlUnpackPrice), payload.IntlUnpackPrice) + suite.Equal(handlers.FmtCost(ppmCloseout.IntlLinehaulPrice), payload.IntlLinehaulPrice) + suite.Equal(handlers.FmtCost(ppmCloseout.SITReimbursement), payload.SITReimbursement) +} + func (suite *PayloadsSuite) TestPaymentServiceItemPayload() { mtoServiceItemID := uuid.Must(uuid.NewV4()) mtoShipmentID := uuid.Must(uuid.NewV4()) diff --git a/pkg/handlers/ghcapi/mto_shipment_test.go b/pkg/handlers/ghcapi/mto_shipment_test.go index 67c79389e8c..987835b493d 100644 --- a/pkg/handlers/ghcapi/mto_shipment_test.go +++ b/pkg/handlers/ghcapi/mto_shipment_test.go @@ -4313,9 +4313,7 @@ func (suite *HandlerSuite) TestUpdateShipmentHandler() { suite.Equal(handlers.FmtPoundPtr(&sitEstimatedWeight), updatedShipment.PpmShipment.SitEstimatedWeight) suite.Equal(handlers.FmtDate(sitEstimatedEntryDate), updatedShipment.PpmShipment.SitEstimatedEntryDate) suite.Equal(handlers.FmtDate(sitEstimatedDepartureDate), updatedShipment.PpmShipment.SitEstimatedDepartureDate) - suite.Equal(int64(sitEstimatedCost), *updatedShipment.PpmShipment.SitEstimatedCost) suite.Equal(handlers.FmtPoundPtr(&estimatedWeight), updatedShipment.PpmShipment.EstimatedWeight) - suite.Equal(int64(estimatedIncentive), *updatedShipment.PpmShipment.EstimatedIncentive) suite.Equal(handlers.FmtBool(hasProGear), updatedShipment.PpmShipment.HasProGear) suite.Equal(handlers.FmtPoundPtr(&proGearWeight), updatedShipment.PpmShipment.ProGearWeight) suite.Equal(handlers.FmtPoundPtr(&spouseProGearWeight), updatedShipment.PpmShipment.SpouseProGearWeight) diff --git a/pkg/models/port_location.go b/pkg/models/port_location.go index 4d514a2a545..414006d9c82 100644 --- a/pkg/models/port_location.go +++ b/pkg/models/port_location.go @@ -3,7 +3,10 @@ package models import ( "time" + "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" + + "github.com/transcom/mymove/pkg/apperror" ) type PortLocation struct { @@ -24,3 +27,12 @@ type PortLocation struct { func (l PortLocation) TableName() string { return "port_locations" } + +func FetchPortLocationByCode(db *pop.Connection, portCode string) (*PortLocation, error) { + portLocation := PortLocation{} + err := db.Eager("Port", "UsPostRegionCity").Where("is_active = TRUE").InnerJoin("ports p", "port_id = p.id").Where("p.port_code = $1", portCode).First(&portLocation) + if err != nil { + return nil, apperror.NewQueryError("PortLocation", err, "") + } + return &portLocation, err +} diff --git a/pkg/models/port_location_test.go b/pkg/models/port_location_test.go index c63a4e34e29..ae50c68880f 100644 --- a/pkg/models/port_location_test.go +++ b/pkg/models/port_location_test.go @@ -2,6 +2,7 @@ package models_test import ( "github.com/transcom/mymove/pkg/factory" + "github.com/transcom/mymove/pkg/models" ) func (suite *ModelSuite) TestPortLocation() { @@ -24,3 +25,28 @@ func (suite *ModelSuite) TestPortLocation() { suite.Equal("port_locations", portLocation.TableName()) }) } + +func (suite *ModelSuite) TestFetchPortLocationByCode() { + suite.Run("Port location can be fetched when it exists", func() { + + portLocation := factory.FetchPortLocation(suite.DB(), []factory.Customization{ + { + Model: models.Port{ + PortCode: "SEA", + }, + }, + }, nil) + suite.NotNil(portLocation) + + result, err := models.FetchPortLocationByCode(suite.AppContextForTest().DB(), "SEA") + suite.NotNil(result) + suite.NoError(err) + suite.Equal(portLocation.ID, result.ID) + }) + + suite.Run("Sends back an error when it does not exist", func() { + result, err := models.FetchPortLocationByCode(suite.AppContextForTest().DB(), "123") + suite.Nil(result) + suite.Error(err) + }) +} diff --git a/pkg/models/ppm_shipment.go b/pkg/models/ppm_shipment.go index 0737417207c..d53bdb5b3ac 100644 --- a/pkg/models/ppm_shipment.go +++ b/pkg/models/ppm_shipment.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "time" "github.com/gobuffalo/pop/v6" @@ -34,11 +35,14 @@ type PPMCloseout struct { RemainingIncentive *unit.Cents HaulPrice *unit.Cents HaulFSC *unit.Cents - HaulType HaulType + HaulType *HaulType DOP *unit.Cents DDP *unit.Cents PackPrice *unit.Cents UnpackPrice *unit.Cents + IntlPackPrice *unit.Cents + IntlUnpackPrice *unit.Cents + IntlLinehaulPrice *unit.Cents SITReimbursement *unit.Cents } @@ -319,3 +323,44 @@ func FetchPPMShipmentByPPMShipmentID(db *pop.Connection, ppmShipmentID uuid.UUID } return &ppmShipment, nil } + +type PPMIncentiveOCONUS struct { + TotalIncentive int `db:"total_incentive"` + PriceISLH int `db:"price_islh"` + PriceIHPK int `db:"price_ihpk"` + PriceIHUPK int `db:"price_ihupk"` + PriceFSC int `db:"price_fsc"` +} + +// a db function that will handle updating the estimated_incentive value +// this simulates pricing of a basic iHHG shipment with ISLH, IHPK, IHUPK, and the CONUS portion for a FSC +func CalculatePPMIncentive(db *pop.Connection, ppmID uuid.UUID, pickupAddressID uuid.UUID, destAddressID uuid.UUID, moveDate time.Time, mileage int, weight int, isEstimated bool, isActual bool, isMax bool) (*PPMIncentiveOCONUS, error) { + var incentive PPMIncentiveOCONUS + + err := db.RawQuery("SELECT * FROM calculate_ppm_incentive($1, $2, $3, $4, $5, $6, $7, $8, $9)", ppmID, pickupAddressID, destAddressID, moveDate, mileage, weight, isEstimated, isActual, isMax). + First(&incentive) + if err != nil { + return nil, fmt.Errorf("error calculating PPM incentive for PPM ID %s: %w", ppmID, err) + } + + return &incentive, nil +} + +type PPMSITCosts struct { + TotalSITCost int `db:"total_cost"` + PriceFirstDaySIT int `db:"price_first_day"` + PriceAddlDaySIT int `db:"price_addl_day"` +} + +// a db function that will handle calculating and returning the SIT costs related to a PPM shipment +func CalculatePPMSITCost(db *pop.Connection, ppmID uuid.UUID, addressID uuid.UUID, isOrigin bool, moveDate time.Time, weight int, sitDays int) (*PPMSITCosts, error) { + var costs PPMSITCosts + + err := db.RawQuery("SELECT * FROM calculate_ppm_SIT_cost($1, $2, $3, $4, $5, $6)", ppmID, addressID, isOrigin, moveDate, weight, sitDays). + First(&costs) + if err != nil { + return nil, fmt.Errorf("error calculating PPM SIT costs for PPM ID %s: %w", ppmID, err) + } + + return &costs, nil +} diff --git a/pkg/models/ppm_shipment_test.go b/pkg/models/ppm_shipment_test.go index 4def2567968..7dbeb27f545 100644 --- a/pkg/models/ppm_shipment_test.go +++ b/pkg/models/ppm_shipment_test.go @@ -7,6 +7,7 @@ import ( "github.com/gofrs/uuid" + "github.com/transcom/mymove/pkg/factory" "github.com/transcom/mymove/pkg/models" "github.com/transcom/mymove/pkg/testdatagen" "github.com/transcom/mymove/pkg/unit" @@ -125,3 +126,161 @@ func (suite *ModelSuite) TestPPMShipmentValidation() { }) } } + +func (suite *ModelSuite) TestCalculatePPMIncentive() { + suite.Run("success - receive PPM incentive when all values exist", func() { + ppmShipment := factory.BuildPPMShipment(suite.DB(), nil, nil) + pickupUSPRC, err := models.FindByZipCode(suite.AppContextForTest().DB(), "74135") + suite.FatalNoError(err) + pickupAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + IsOconus: models.BoolPointer(false), + UsPostRegionCityID: &pickupUSPRC.ID, + }, + }, + }, nil) + + destUSPRC, err := models.FindByZipCode(suite.AppContextForTest().DB(), "99505") + suite.FatalNoError(err) + destAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + UsPostRegionCityID: &destUSPRC.ID, + }, + }, + }, nil) + + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + StartDate: time.Now().Add(-24 * time.Hour), + EndDate: time.Now().Add(24 * time.Hour), + }, + }) + moveDate := time.Now() + mileage := 1000 + weight := 2000 + + incentives, err := models.CalculatePPMIncentive(suite.DB(), ppmShipment.ID, pickupAddress.ID, destAddress.ID, moveDate, mileage, weight, true, false, false) + suite.NoError(err) + suite.NotNil(incentives) + suite.NotNil(incentives.PriceFSC) + suite.NotNil(incentives.PriceIHPK) + suite.NotNil(incentives.PriceIHUPK) + suite.NotNil(incentives.PriceISLH) + suite.NotNil(incentives.TotalIncentive) + }) + + suite.Run("failure - contract doesn't exist", func() { + ppmShipment := factory.BuildPPMShipment(suite.DB(), nil, nil) + pickupUSPRC, err := models.FindByZipCode(suite.AppContextForTest().DB(), "74135") + suite.FatalNoError(err) + pickupAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + IsOconus: models.BoolPointer(false), + UsPostRegionCityID: &pickupUSPRC.ID, + }, + }, + }, nil) + + destUSPRC, err := models.FindByZipCode(suite.AppContextForTest().DB(), "99505") + suite.FatalNoError(err) + destAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + UsPostRegionCityID: &destUSPRC.ID, + }, + }, + }, nil) + + moveDate := time.Now() + mileage := 1000 + weight := 2000 + + incentives, err := models.CalculatePPMIncentive(suite.DB(), ppmShipment.ID, pickupAddress.ID, destAddress.ID, moveDate, mileage, weight, true, false, false) + suite.Error(err) + suite.Nil(incentives) + }) +} + +func (suite *ModelSuite) TestCalculatePPMSITCost() { + suite.Run("success - receive PPM SIT costs when all values exist", func() { + ppmShipment := factory.BuildPPMShipment(suite.DB(), nil, nil) + destUSPRC, err := models.FindByZipCode(suite.AppContextForTest().DB(), "99505") + suite.FatalNoError(err) + address := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + UsPostRegionCityID: &destUSPRC.ID, + }, + }, + }, nil) + + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + StartDate: time.Now().Add(-24 * time.Hour), + EndDate: time.Now().Add(24 * time.Hour), + }, + }) + moveDate := time.Now() + sitDays := 7 + weight := 2000 + + sitCost, err := models.CalculatePPMSITCost(suite.DB(), ppmShipment.ID, address.ID, false, moveDate, weight, sitDays) + suite.NoError(err) + suite.NotNil(sitCost) + suite.NotNil(sitCost.PriceAddlDaySIT) + suite.NotNil(sitCost.PriceFirstDaySIT) + suite.NotNil(sitCost.TotalSITCost) + }) + + suite.Run("failure - contract doesn't exist", func() { + ppmShipment := factory.BuildPPMShipment(suite.DB(), nil, nil) + destUSPRC, err := models.FindByZipCode(suite.AppContextForTest().DB(), "99505") + suite.FatalNoError(err) + address := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + UsPostRegionCityID: &destUSPRC.ID, + }, + }, + }, nil) + + moveDate := time.Now() + sitDays := 7 + weight := 2000 + + sitCost, err := models.CalculatePPMSITCost(suite.DB(), ppmShipment.ID, address.ID, false, moveDate, weight, sitDays) + suite.Error(err) + suite.Nil(sitCost) + }) +} diff --git a/pkg/models/re_intl_other_price.go b/pkg/models/re_intl_other_price.go index b8dce673214..1d95b5fcdd8 100644 --- a/pkg/models/re_intl_other_price.go +++ b/pkg/models/re_intl_other_price.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "time" "github.com/gobuffalo/pop/v6" @@ -45,3 +46,36 @@ func (r *ReIntlOtherPrice) Validate(_ *pop.Connection) (*validate.Errors, error) &validators.IntIsGreaterThan{Field: r.PerUnitCents.Int(), Name: "PerUnitCents", Compared: -1}, ), nil } + +// fetches a row from re_intl_other_prices using passed in parameters +// gets the rate_area_id & is_peak_period based on values provided +func FetchReIntlOtherPrice(db *pop.Connection, addressID uuid.UUID, serviceID uuid.UUID, contractID uuid.UUID, referenceDate *time.Time) (*ReIntlOtherPrice, error) { + if addressID != uuid.Nil && serviceID != uuid.Nil && contractID != uuid.Nil && referenceDate != nil { + // need to get the rate area first + rateAreaID, err := FetchRateAreaID(db, addressID, &serviceID, contractID) + if err != nil { + return nil, fmt.Errorf("error fetching rate area id for shipment ID: %s, service ID %s, and contract ID: %s: %s", addressID, serviceID, contractID, err) + } + + var isPeakPeriod bool + err = db.RawQuery("SELECT is_peak_period($1)", referenceDate).First(&isPeakPeriod) + if err != nil { + return nil, fmt.Errorf("error checking if date is peak period with date: %s: %s", contractID, err) + } + + var reIntlOtherPrice ReIntlOtherPrice + err = db.Q(). + Where("contract_id = ?", contractID). + Where("service_id = ?", serviceID). + Where("is_peak_period = ?", isPeakPeriod). + Where("rate_area_id = ?", rateAreaID). + First(&reIntlOtherPrice) + if err != nil { + return nil, fmt.Errorf("error fetching row from re_int_other_prices using rateAreaID %s, service ID %s, and contract ID: %s: %s", rateAreaID, serviceID, contractID, err) + } + + return &reIntlOtherPrice, nil + } + + return nil, fmt.Errorf("error value from re_intl_other_prices - required parameters not provided") +} diff --git a/pkg/models/re_intl_other_price_test.go b/pkg/models/re_intl_other_price_test.go index 69e9770c47a..8cde04c61d4 100644 --- a/pkg/models/re_intl_other_price_test.go +++ b/pkg/models/re_intl_other_price_test.go @@ -1,9 +1,13 @@ package models_test import ( + "time" + "github.com/gofrs/uuid" + "github.com/transcom/mymove/pkg/factory" "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/testdatagen" ) func (suite *ModelSuite) TestReIntlOtherPriceValidation() { @@ -43,3 +47,76 @@ func (suite *ModelSuite) TestReIntlOtherPriceValidation() { suite.verifyValidationErrors(&intlOtherPrice, expErrors) }) } + +func (suite *ModelSuite) TestFetchReIntlOtherPrice() { + suite.Run("success - receive ReIntlOtherPrice when all values exist and are found", func() { + address := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + + reService, err := models.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIHPK) + suite.NoError(err) + suite.NotNil(reService) + + contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + moveDate := time.Now() + + reIntlOtherPrice, err := models.FetchReIntlOtherPrice(suite.DB(), address.ID, reService.ID, contract.ID, &moveDate) + suite.NoError(err) + suite.NotNil(reIntlOtherPrice) + suite.NotNil(reIntlOtherPrice.PerUnitCents) + }) + + suite.Run("failure - receive error when values aren't provided", func() { + address := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + + reService, err := models.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIHPK) + suite.NoError(err) + suite.NotNil(reService) + + contract := testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{}) + moveDate := time.Now() + + // no address + reIntlOtherPrice, err := models.FetchReIntlOtherPrice(suite.DB(), uuid.Nil, reService.ID, contract.ID, &moveDate) + suite.Error(err) + suite.Nil(reIntlOtherPrice) + suite.Contains(err.Error(), "error value from re_intl_other_prices - required parameters not provided") + + // no service ID + reIntlOtherPrice, err = models.FetchReIntlOtherPrice(suite.DB(), address.ID, uuid.Nil, contract.ID, &moveDate) + suite.Error(err) + suite.Nil(reIntlOtherPrice) + suite.Contains(err.Error(), "error value from re_intl_other_prices - required parameters not provided") + + // no contract ID + reIntlOtherPrice, err = models.FetchReIntlOtherPrice(suite.DB(), address.ID, reService.ID, uuid.Nil, &moveDate) + suite.Error(err) + suite.Nil(reIntlOtherPrice) + suite.Contains(err.Error(), "error value from re_intl_other_prices - required parameters not provided") + + // no move date + reIntlOtherPrice, err = models.FetchReIntlOtherPrice(suite.DB(), address.ID, reService.ID, contract.ID, nil) + suite.Error(err) + suite.Nil(reIntlOtherPrice) + suite.Contains(err.Error(), "error value from re_intl_other_prices - required parameters not provided") + }) +} diff --git a/pkg/models/re_service.go b/pkg/models/re_service.go index bab371ac5d4..85136a4705a 100644 --- a/pkg/models/re_service.go +++ b/pkg/models/re_service.go @@ -1,12 +1,15 @@ package models import ( + "fmt" "time" "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" "github.com/gofrs/uuid" + + "github.com/transcom/mymove/pkg/apperror" ) // ReServiceCode is the code of service @@ -224,3 +227,16 @@ func (r *ReService) Validate(_ *pop.Connection) (*validate.Errors, error) { &validators.StringIsPresent{Field: r.Name, Name: "Name"}, ), nil } + +func FetchReServiceByCode(db *pop.Connection, code ReServiceCode) (*ReService, error) { + var reServiceCode ReServiceCode + if code != reServiceCode { + reService := ReService{} + err := db.Where("code = ?", code).First(&reService) + if err != nil { + return nil, apperror.NewQueryError("ReService", err, "") + } + return &reService, err + } + return nil, fmt.Errorf("error fetching from re_services - required code not provided") +} diff --git a/pkg/models/re_service_test.go b/pkg/models/re_service_test.go index 929677faab7..41e60c55d73 100644 --- a/pkg/models/re_service_test.go +++ b/pkg/models/re_service_test.go @@ -23,3 +23,19 @@ func (suite *ModelSuite) TestReServiceValidation() { suite.verifyValidationErrors(&emptyReService, expErrors) }) } + +func (suite *ModelSuite) TestFetchReServiceBycode() { + suite.Run("success - receive ReService when code is provided", func() { + reService, err := models.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIHPK) + suite.NoError(err) + suite.NotNil(reService) + }) + + suite.Run("failure - receive error when code is not provided", func() { + var blankReServiceCode models.ReServiceCode + reService, err := models.FetchReServiceByCode(suite.DB(), blankReServiceCode) + suite.Error(err) + suite.Nil(reService) + suite.Contains(err.Error(), "error fetching from re_services - required code not provided") + }) +} diff --git a/pkg/payment_request/service_param_value_lookups/distance_zip_lookup.go b/pkg/payment_request/service_param_value_lookups/distance_zip_lookup.go index 0317726d032..acc87eb2715 100644 --- a/pkg/payment_request/service_param_value_lookups/distance_zip_lookup.go +++ b/pkg/payment_request/service_param_value_lookups/distance_zip_lookup.go @@ -48,6 +48,8 @@ func (r DistanceZipLookup) lookup(appCtx appcontext.AppContext, keyData *Service "Distance", "PickupAddress", "DestinationAddress", + "PPMShipment.PickupAddress", + "PPMShipment.DestinationAddress", ).Find(&mtoShipment, mtoShipment.ID) if err != nil { return "", err @@ -61,20 +63,47 @@ func (r DistanceZipLookup) lookup(appCtx appcontext.AppContext, keyData *Service // if the shipment is international, we need to change the respective ZIP to use the port ZIP and not the address ZIP if isInternationalShipment { - portZip, portType, err := models.GetPortLocationInfoForShipment(appCtx.DB(), *mtoShipmentID) - if err != nil { - return "", err - } - if portZip != nil && portType != nil { - // if the port type is POEFSC this means the shipment is CONUS -> OCONUS (pickup -> port) - // if the port type is PODFSC this means the shipment is OCONUS -> CONUS (port -> destination) - if *portType == models.ReServiceCodePOEFSC.String() { - destinationZip = *portZip - } else if *portType == models.ReServiceCodePODFSC.String() { - pickupZip = *portZip + if mtoShipment.ShipmentType != models.MTOShipmentTypePPM { + portZip, portType, err := models.GetPortLocationInfoForShipment(appCtx.DB(), *mtoShipmentID) + if err != nil { + return "", err + } + if portZip != nil && portType != nil { + // if the port type is POEFSC this means the shipment is CONUS -> OCONUS (pickup -> port) + // if the port type is PODFSC this means the shipment is OCONUS -> CONUS (port -> destination) + if *portType == models.ReServiceCodePOEFSC.String() { + destinationZip = *portZip + } else if *portType == models.ReServiceCodePODFSC.String() { + pickupZip = *portZip + } + } else { + return "", apperror.NewNotFoundError(*mtoShipmentID, "looking for port ZIP for shipment") } } else { - return "", apperror.NewNotFoundError(*mtoShipmentID, "looking for port ZIP for shipment") + // PPMs get reimbursed for their travel from CONUS <-> Port ZIPs, but only for the Tacoma Port + portLocation, err := models.FetchPortLocationByCode(appCtx.DB(), "4E1") // Tacoma port code + if err != nil { + return "", fmt.Errorf("unable to find port zip with code %s", "4E1") + } + if mtoShipment.PPMShipment != nil && mtoShipment.PPMShipment.PickupAddress != nil && mtoShipment.PPMShipment.DestinationAddress != nil { + // need to figure out if we are going to go Port -> CONUS or CONUS -> Port + pickupOconus := *mtoShipment.PPMShipment.PickupAddress.IsOconus + destOconus := *mtoShipment.PPMShipment.DestinationAddress.IsOconus + if pickupOconus && !destOconus { + // Port ZIP -> CONUS ZIP + pickupZip = portLocation.UsPostRegionCity.UsprZipID + destinationZip = mtoShipment.PPMShipment.DestinationAddress.PostalCode + } else if !pickupOconus && destOconus { + // CONUS ZIP -> Port ZIP + pickupZip = mtoShipment.PPMShipment.PickupAddress.PostalCode + destinationZip = portLocation.UsPostRegionCity.UsprZipID + } else { + // OCONUS -> OCONUS mileage they don't get reimbursed for this + return strconv.Itoa(0), nil + } + } else { + return "", fmt.Errorf("missing required PPM & address information for shipment with id %s", mtoShipmentID) + } } } errorMsgForPickupZip := fmt.Sprintf("Shipment must have valid pickup zipcode. Received: %s", pickupZip) diff --git a/pkg/payment_request/service_param_value_lookups/distance_zip_lookup_test.go b/pkg/payment_request/service_param_value_lookups/distance_zip_lookup_test.go index 48c920919d0..8b8d866b313 100644 --- a/pkg/payment_request/service_param_value_lookups/distance_zip_lookup_test.go +++ b/pkg/payment_request/service_param_value_lookups/distance_zip_lookup_test.go @@ -125,6 +125,56 @@ func (suite *ServiceParamValueLookupsSuite) TestDistanceLookup() { suite.Equal(unit.Miles(defaultInternationalZipDistance), *mtoShipment.Distance) }) + suite.Run("Call ZipTransitDistance on international PPMs with CONUS -> Tacoma Port ZIP", func() { + miles := unit.Miles(defaultZipDistance) + + ppmShipment := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + Distance: &miles, + ShipmentType: models.MTOShipmentTypePPM, + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + distanceZipLookup := DistanceZipLookup{ + PickupAddress: models.Address{PostalCode: ppmShipment.PickupAddress.PostalCode}, + DestinationAddress: models.Address{PostalCode: ppmShipment.DestinationAddress.PostalCode}, + } + + appContext := suite.AppContextForTest() + distance, err := distanceZipLookup.lookup(appContext, &ServiceItemParamKeyData{ + planner: suite.planner, + mtoShipmentID: &ppmShipment.ShipmentID, + }) + suite.NoError(err) + suite.NotNil(distance) + + planner := suite.planner.(*mocks.Planner) + // should be called with the 98421 ZIP of the Tacoma port and NOT 99505 + planner.AssertCalled(suite.T(), "ZipTransitDistance", appContext, ppmShipment.PickupAddress.PostalCode, "98421", true) + }) + suite.Run("Calculate transit zip distance with an approved Destination SIT service item", func() { testdatagen.MakeReContractYear(suite.DB(), testdatagen.Assertions{ ReContractYear: models.ReContractYear{ diff --git a/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go b/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go index f8efda9d451..37032a5ccf9 100644 --- a/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go +++ b/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go @@ -2,6 +2,9 @@ package serviceparamvaluelookups import ( "fmt" + "time" + + "github.com/gofrs/uuid" "github.com/transcom/mymove/pkg/appcontext" "github.com/transcom/mymove/pkg/models" @@ -16,19 +19,67 @@ type PerUnitCentsLookup struct { func (p PerUnitCentsLookup) lookup(appCtx appcontext.AppContext, s *ServiceItemParamKeyData) (string, error) { serviceID := p.ServiceItem.ReServiceID + if serviceID == uuid.Nil { + reService, err := models.FetchReServiceByCode(appCtx.DB(), p.ServiceItem.ReService.Code) + if err != nil { + return "", fmt.Errorf("error fetching ReService Code %s: %w", p.ServiceItem.ReService.Code, err) + } + serviceID = reService.ID + } contractID := s.ContractID - if p.MTOShipment.RequestedPickupDate == nil { - return "", fmt.Errorf("requested pickup date is required for shipment with id: %s", p.MTOShipment.ID) + var shipmentID uuid.UUID + var pickupAddressID uuid.UUID + var destinationAddressID uuid.UUID + var moveDate time.Time + // HHG shipment + if p.MTOShipment.ShipmentType != models.MTOShipmentTypePPM { + shipmentID = p.MTOShipment.ID + if p.MTOShipment.RequestedPickupDate != nil { + moveDate = *p.MTOShipment.RequestedPickupDate + } else { + return "", fmt.Errorf("requested pickup date is required for shipment with id: %s", shipmentID) + } + if p.MTOShipment.PickupAddressID != nil { + pickupAddressID = *p.MTOShipment.PickupAddressID + } else { + return "", fmt.Errorf("pickup address is required for shipment with id: %s", shipmentID) + } + if p.MTOShipment.DestinationAddressID != nil { + destinationAddressID = *p.MTOShipment.DestinationAddressID + } else { + return "", fmt.Errorf("destination address is required for shipment with id: %s", shipmentID) + } + } else { // PPM shipment + shipmentID = p.MTOShipment.PPMShipment.ID + if p.MTOShipment.ActualPickupDate != nil { + moveDate = *p.MTOShipment.ActualPickupDate + } else if p.MTOShipment.RequestedPickupDate != nil { + moveDate = *p.MTOShipment.RequestedPickupDate + } else { + return "", fmt.Errorf("actual move date is required for PPM shipment with id: %s", shipmentID) + } + + if p.MTOShipment.PPMShipment.PickupAddressID != nil { + pickupAddressID = *p.MTOShipment.PPMShipment.PickupAddressID + } else { + return "", fmt.Errorf("pickup address is required for PPM shipment with id: %s", shipmentID) + } + + if p.MTOShipment.PPMShipment.DestinationAddressID != nil { + destinationAddressID = *p.MTOShipment.PPMShipment.DestinationAddressID + } else { + return "", fmt.Errorf("destination address is required for PPM shipment with id: %s", shipmentID) + } } switch p.ServiceItem.ReService.Code { case models.ReServiceCodeIHPK: // IHPK: Need rate area ID for the pickup address - rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, &serviceID, contractID) + rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), pickupAddressID, &serviceID, contractID) if err != nil { - return "", fmt.Errorf("error fetching rate area id for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) + return "", fmt.Errorf("error fetching rate area id for shipment ID: %s and service ID %s: %s", shipmentID, serviceID, err) } - isPeakPeriod := ghcrateengine.IsPeakPeriod(*p.MTOShipment.RequestedPickupDate) + isPeakPeriod := ghcrateengine.IsPeakPeriod(moveDate) var reIntlOtherPrice models.ReIntlOtherPrice err = appCtx.DB().Q(). Where("contract_id = ?", contractID). @@ -43,11 +94,11 @@ func (p PerUnitCentsLookup) lookup(appCtx appcontext.AppContext, s *ServiceItemP case models.ReServiceCodeIHUPK: // IHUPK: Need rate area ID for the destination address - rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.DestinationAddressID, &serviceID, contractID) + rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), destinationAddressID, &serviceID, contractID) if err != nil { - return "", fmt.Errorf("error fetching rate area id for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) + return "", fmt.Errorf("error fetching rate area id for shipment ID: %s and service ID %s: %s", shipmentID, serviceID, err) } - isPeakPeriod := ghcrateengine.IsPeakPeriod(*p.MTOShipment.RequestedPickupDate) + isPeakPeriod := ghcrateengine.IsPeakPeriod(moveDate) var reIntlOtherPrice models.ReIntlOtherPrice err = appCtx.DB().Q(). Where("contract_id = ?", contractID). @@ -62,15 +113,15 @@ func (p PerUnitCentsLookup) lookup(appCtx appcontext.AppContext, s *ServiceItemP case models.ReServiceCodeISLH: // ISLH: Need rate area IDs for origin and destination - originRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, &serviceID, contractID) + originRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), pickupAddressID, &serviceID, contractID) if err != nil { - return "", fmt.Errorf("error fetching rate area id for origin address for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) + return "", fmt.Errorf("error fetching rate area id for origin address for shipment ID: %s and service ID %s: %s", shipmentID, serviceID, err) } - destRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.DestinationAddressID, &serviceID, contractID) + destRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), destinationAddressID, &serviceID, contractID) if err != nil { - return "", fmt.Errorf("error fetching rate area id for destination address for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) + return "", fmt.Errorf("error fetching rate area id for destination address for shipment ID: %s and service ID %s: %s", shipmentID, serviceID, err) } - isPeakPeriod := ghcrateengine.IsPeakPeriod(*p.MTOShipment.RequestedPickupDate) + isPeakPeriod := ghcrateengine.IsPeakPeriod(moveDate) var reIntlPrice models.ReIntlPrice err = appCtx.DB().Q(). Where("contract_id = ?", contractID). @@ -84,6 +135,82 @@ func (p PerUnitCentsLookup) lookup(appCtx appcontext.AppContext, s *ServiceItemP } return reIntlPrice.PerUnitCents.ToMillicents().ToCents().String(), nil + case models.ReServiceCodeIOFSIT: + // IOFSIT: Need rate area ID for origin + originRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), pickupAddressID, &serviceID, contractID) + if err != nil { + return "", fmt.Errorf("error fetching rate area id for origin address for shipment ID: %s and service ID %s: %s", shipmentID, serviceID, err) + } + isPeakPeriod := ghcrateengine.IsPeakPeriod(moveDate) + var reIntlOtherPrice models.ReIntlOtherPrice + err = appCtx.DB().Q(). + Where("contract_id = ?", contractID). + Where("service_id = ?", serviceID). + Where("is_peak_period = ?", isPeakPeriod). + Where("rate_area_id = ?", originRateAreaID). + First(&reIntlOtherPrice) + if err != nil { + return "", fmt.Errorf("error fetching IOFSIT per unit cents for contractID: %s, serviceID %s, isPeakPeriod: %t, originRateAreaID: %s: %s", contractID, serviceID, isPeakPeriod, originRateAreaID, err) + } + return reIntlOtherPrice.PerUnitCents.ToMillicents().ToCents().String(), nil + + case models.ReServiceCodeIOASIT: + // IOASIT: Need rate area ID for origin + originRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), pickupAddressID, &serviceID, contractID) + if err != nil { + return "", fmt.Errorf("error fetching rate area id for origin address for shipment ID: %s, service ID %s: %s", shipmentID, serviceID, err) + } + isPeakPeriod := ghcrateengine.IsPeakPeriod(moveDate) + var reIntlOtherPrice models.ReIntlOtherPrice + err = appCtx.DB().Q(). + Where("contract_id = ?", contractID). + Where("service_id = ?", serviceID). + Where("is_peak_period = ?", isPeakPeriod). + Where("rate_area_id = ?", originRateAreaID). + First(&reIntlOtherPrice) + if err != nil { + return "", fmt.Errorf("error fetching IOASIT per unit cents for contractID: %s, serviceID %s, isPeakPeriod: %t, originRateAreaID: %s: %s", contractID, serviceID, isPeakPeriod, originRateAreaID, err) + } + return reIntlOtherPrice.PerUnitCents.ToMillicents().ToCents().String(), nil + + case models.ReServiceCodeIDFSIT: + // IDFSIT: Need rate area ID for destination + destRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), destinationAddressID, &serviceID, contractID) + if err != nil { + return "", fmt.Errorf("error fetching rate area id for destination address for shipment ID: %s, service ID %s: %s", shipmentID, serviceID, err) + } + isPeakPeriod := ghcrateengine.IsPeakPeriod(moveDate) + var reIntlOtherPrice models.ReIntlOtherPrice + err = appCtx.DB().Q(). + Where("contract_id = ?", contractID). + Where("service_id = ?", serviceID). + Where("is_peak_period = ?", isPeakPeriod). + Where("rate_area_id = ?", destRateAreaID). + First(&reIntlOtherPrice) + if err != nil { + return "", fmt.Errorf("error fetching IDFSIT per unit cents for contractID: %s, serviceID %s, isPeakPeriod: %t, destRateAreaID: %s: %s", contractID, serviceID, isPeakPeriod, destRateAreaID, err) + } + return reIntlOtherPrice.PerUnitCents.ToMillicents().ToCents().String(), nil + + case models.ReServiceCodeIDASIT: + // IDASIT: Need rate area ID for destination + destRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), destinationAddressID, &serviceID, contractID) + if err != nil { + return "", fmt.Errorf("error fetching rate area id for destination address for shipment ID: %s and service ID %s: %s", shipmentID, serviceID, err) + } + isPeakPeriod := ghcrateengine.IsPeakPeriod(moveDate) + var reIntlOtherPrice models.ReIntlOtherPrice + err = appCtx.DB().Q(). + Where("contract_id = ?", contractID). + Where("service_id = ?", serviceID). + Where("is_peak_period = ?", isPeakPeriod). + Where("rate_area_id = ?", destRateAreaID). + First(&reIntlOtherPrice) + if err != nil { + return "", fmt.Errorf("error fetching IDASIT per unit cents for contractID: %s, serviceID %s, isPeakPeriod: %t, destRateAreaID: %s: %s", contractID, serviceID, isPeakPeriod, destRateAreaID, err) + } + return reIntlOtherPrice.PerUnitCents.ToMillicents().ToCents().String(), nil + default: return "", fmt.Errorf("unsupported service code to retrieve service item param PerUnitCents") } diff --git a/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup_test.go b/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup_test.go index 9937f86217b..dc69b69f888 100644 --- a/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup_test.go +++ b/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup_test.go @@ -30,6 +30,105 @@ func (suite *ServiceParamValueLookupsSuite) TestPerUnitCentsLookup() { } + setupTestDataPickupOCONUS := func(serviceCode models.ReServiceCode) models.Move { + testdatagen.MakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + StartDate: time.Now().Add(-24 * time.Hour), + EndDate: time.Now().Add(24 * time.Hour), + }, + }) + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + address := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + shipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + PickupAddressID: &address.ID, + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: move, + LinkOnly: true, + }, + }, nil) + mtoServiceItem = factory.BuildMTOServiceItem(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + { + Model: shipment, + LinkOnly: true, + }, + { + Model: models.ReService{ + Code: serviceCode, + }, + }, + }, nil) + + return move + } + + setupTestDataDestOCONUS := func(serviceCode models.ReServiceCode) models.Move { + testdatagen.MakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + StartDate: time.Now().Add(-24 * time.Hour), + EndDate: time.Now().Add(24 * time.Hour), + }, + }) + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + address := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + shipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + DestinationAddressID: &address.ID, + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: move, + LinkOnly: true, + }, + }, nil) + mtoServiceItem = factory.BuildMTOServiceItem(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + { + Model: shipment, + LinkOnly: true, + }, + { + Model: models.ReService{ + Code: serviceCode, + }, + }, + }, nil) + return move + } + suite.Run("success - returns perUnitCent value for IHPK", func() { setupTestData(models.ReServiceCodeIHPK) @@ -119,6 +218,115 @@ func (suite *ServiceParamValueLookupsSuite) TestPerUnitCentsLookup() { suite.Equal(perUnitCents, "1605") }) + suite.Run("success - returns perUnitCent value for IOFSIT", func() { + move := setupTestDataPickupOCONUS(models.ReServiceCodeIOFSIT) + + paramLookup, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, uuid.Must(uuid.NewV4()), move.ID, nil) + suite.FatalNoError(err) + + perUnitCents, err := paramLookup.ServiceParamValue(suite.AppContextForTest(), key) + suite.FatalNoError(err) + suite.Equal(perUnitCents, "607") + }) + + suite.Run("success - returns perUnitCent value for IOASIT", func() { + move := setupTestDataPickupOCONUS(models.ReServiceCodeIOASIT) + + paramLookup, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, uuid.Must(uuid.NewV4()), move.ID, nil) + suite.FatalNoError(err) + + perUnitCents, err := paramLookup.ServiceParamValue(suite.AppContextForTest(), key) + suite.FatalNoError(err) + suite.Equal(perUnitCents, "14") + }) + + suite.Run("success - returns perUnitCent value for IDFSIT", func() { + move := setupTestDataDestOCONUS(models.ReServiceCodeIDFSIT) + + paramLookup, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, uuid.Must(uuid.NewV4()), move.ID, nil) + suite.FatalNoError(err) + + perUnitCents, err := paramLookup.ServiceParamValue(suite.AppContextForTest(), key) + suite.FatalNoError(err) + suite.Equal(perUnitCents, "607") + }) + + suite.Run("success - returns perUnitCent value for IDASIT", func() { + move := setupTestDataDestOCONUS(models.ReServiceCodeIDASIT) + + paramLookup, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, uuid.Must(uuid.NewV4()), move.ID, nil) + suite.FatalNoError(err) + + perUnitCents, err := paramLookup.ServiceParamValue(suite.AppContextForTest(), key) + suite.FatalNoError(err) + suite.Equal(perUnitCents, "14") + }) + + suite.Run("success - returns perUnitCent value for IDASIT for a PPM", func() { + contractYear := testdatagen.MakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + StartDate: time.Now().Add(-24 * time.Hour), + EndDate: time.Now().Add(24 * time.Hour), + }, + }) + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + ActualPickupDate: models.TimePointer(time.Now()), + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + mtoServiceItem = factory.BuildMTOServiceItem(suite.DB(), []factory.Customization{ + { + Model: models.ReService{ + Code: models.ReServiceCodeIDASIT, + }, + }, + { + Model: move, + LinkOnly: true, + }, + }, nil) + + _, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, uuid.Must(uuid.NewV4()), mtoServiceItem.MoveTaskOrderID, nil) + suite.FatalNoError(err) + + perUnitCentsLookup := PerUnitCentsLookup{ + ServiceItem: mtoServiceItem, + MTOShipment: ppm.Shipment, + } + + appContext := suite.AppContextForTest() + perUnitCents, err := perUnitCentsLookup.lookup(appContext, &ServiceItemParamKeyData{ + planner: suite.planner, + mtoShipmentID: &ppm.ShipmentID, + ContractID: contractYear.ContractID, + }) + suite.NoError(err) + suite.Equal(perUnitCents, "14") + }) + suite.Run("failure - unauthorized service code", func() { setupTestData(models.ReServiceCodeDUPK) diff --git a/pkg/payment_request/service_param_value_lookups/port_zip_lookup.go b/pkg/payment_request/service_param_value_lookups/port_zip_lookup.go index 3ea8be94315..9621c56cd8a 100644 --- a/pkg/payment_request/service_param_value_lookups/port_zip_lookup.go +++ b/pkg/payment_request/service_param_value_lookups/port_zip_lookup.go @@ -15,14 +15,27 @@ type PortZipLookup struct { ServiceItem models.MTOServiceItem } -func (p PortZipLookup) lookup(appCtx appcontext.AppContext, _ *ServiceItemParamKeyData) (string, error) { +func (p PortZipLookup) lookup(appCtx appcontext.AppContext, keyData *ServiceItemParamKeyData) (string, error) { var portLocationID *uuid.UUID if p.ServiceItem.PODLocationID != nil { portLocationID = p.ServiceItem.PODLocationID } else if p.ServiceItem.POELocationID != nil { portLocationID = p.ServiceItem.POELocationID } else { - return "", fmt.Errorf("unable to find port zip for service item id: %s", p.ServiceItem.ID) + // for PPMs we need to send back the ZIP for the Tacoma Port, they are reimbursed for their CONUS <-> Port travel + shipment, err := models.FetchShipmentByID(appCtx.DB(), *keyData.mtoShipmentID) + if err != nil { + return "", fmt.Errorf("unable to find shipment with id %s", keyData.mtoShipmentID) + } + if shipment.ShipmentType == models.MTOShipmentTypePPM && shipment.MarketCode == models.MarketCodeInternational { + portLocation, err := models.FetchPortLocationByCode(appCtx.DB(), "4E1") + if err != nil { + return "", fmt.Errorf("unable to find port zip with code %s", "4E1") + } + return portLocation.UsPostRegionCity.UsprZipID, nil + } else { + return "", nil + } } var portLocation models.PortLocation err := appCtx.DB().Q(). diff --git a/pkg/payment_request/service_param_value_lookups/port_zip_lookup_test.go b/pkg/payment_request/service_param_value_lookups/port_zip_lookup_test.go index 4410ba8e198..b06533a4e1f 100644 --- a/pkg/payment_request/service_param_value_lookups/port_zip_lookup_test.go +++ b/pkg/payment_request/service_param_value_lookups/port_zip_lookup_test.go @@ -85,7 +85,76 @@ func (suite *ServiceParamValueLookupsSuite) TestPortZipLookup() { suite.Equal(portZip, port.UsPostRegionCity.UsprZipID) }) - suite.Run("failure - no port zip on service item", func() { + suite.Run("success - returns PortZip value for Port Code 4E1 for PPMs", func() { + port := factory.FetchPortLocation(suite.DB(), []factory.Customization{ + { + Model: models.Port{ + PortCode: "4E1", + }, + }, + }, nil) + + contractYear := testdatagen.MakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + StartDate: time.Now().Add(-24 * time.Hour), + EndDate: time.Now().Add(24 * time.Hour), + }, + }) + + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + ActualPickupDate: models.TimePointer(time.Now()), + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + mtoServiceItem = factory.BuildMTOServiceItem(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + }, nil) + + _, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, uuid.Must(uuid.NewV4()), mtoServiceItem.MoveTaskOrderID, nil) + suite.FatalNoError(err) + + portZipLookup := PortZipLookup{ + ServiceItem: mtoServiceItem, + } + + appContext := suite.AppContextForTest() + portZip, err := portZipLookup.lookup(appContext, &ServiceItemParamKeyData{ + planner: suite.planner, + mtoShipmentID: &ppm.ShipmentID, + ContractID: contractYear.ContractID, + }) + suite.NoError(err) + suite.Equal(portZip, port.UsPostRegionCity.UsprZipID) + }) + + suite.Run("returns nothing if shipment is HHG and service item does not have port info", func() { testdatagen.MakeReContractYear(suite.DB(), testdatagen.Assertions{ ReContractYear: models.ReContractYear{ StartDate: time.Now().Add(-24 * time.Hour), @@ -108,7 +177,8 @@ func (suite *ServiceParamValueLookupsSuite) TestPortZipLookup() { paramLookup, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, uuid.Must(uuid.NewV4()), mtoServiceItem.MoveTaskOrderID, nil) suite.FatalNoError(err) - _, err = paramLookup.ServiceParamValue(suite.AppContextForTest(), key) - suite.Error(err) + portZip, err := paramLookup.ServiceParamValue(suite.AppContextForTest(), key) + suite.NoError(err) + suite.Equal(portZip, "") }) } 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 580ec02bc19..6815d250f28 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 @@ -27,7 +27,7 @@ type ServiceItemParamKeyData struct { paramCache *ServiceParamsCache } -func NewServiceItemParamKeyData(planner route.Planner, lookups map[models.ServiceItemParamName]ServiceItemParamKeyLookup, mtoServiceItem models.MTOServiceItem, mtoShipment models.MTOShipment, contractCode string) ServiceItemParamKeyData { +func NewServiceItemParamKeyData(planner route.Planner, lookups map[models.ServiceItemParamName]ServiceItemParamKeyLookup, mtoServiceItem models.MTOServiceItem, mtoShipment models.MTOShipment, contractCode string, contractID uuid.UUID) ServiceItemParamKeyData { return ServiceItemParamKeyData{ planner: planner, lookups: lookups, @@ -36,6 +36,7 @@ func NewServiceItemParamKeyData(planner route.Planner, lookups map[models.Servic mtoShipmentID: &mtoShipment.ID, MoveTaskOrderID: mtoShipment.MoveTaskOrderID, ContractCode: contractCode, + ContractID: contractID, } } @@ -213,8 +214,11 @@ func ServiceParamLookupInitialize( mtoShipment.DestinationAddress = &destinationAddress switch mtoServiceItem.ReService.Code { - case models.ReServiceCodeDDASIT, models.ReServiceCodeDDDSIT, models.ReServiceCodeDDFSIT, models.ReServiceCodeDDSFSC, models.ReServiceCodeDOASIT, models.ReServiceCodeDOPSIT, models.ReServiceCodeDOFSIT, models.ReServiceCodeDOSFSC: - err := appCtx.DB().Load(&mtoShipment, "SITDurationUpdates") + case models.ReServiceCodeDDASIT, models.ReServiceCodeDDDSIT, models.ReServiceCodeDDFSIT, + models.ReServiceCodeDDSFSC, models.ReServiceCodeDOASIT, models.ReServiceCodeDOPSIT, + models.ReServiceCodeDOFSIT, models.ReServiceCodeDOSFSC, models.ReServiceCodeIOFSIT, + models.ReServiceCodeIOASIT, models.ReServiceCodeIDFSIT, models.ReServiceCodeIDASIT: + err := appCtx.DB().Load(&mtoShipment, "SITDurationUpdates", "PPMShipment.PickupAddress", "PPMShipment.DestinationAddress") if err != nil { return nil, err } diff --git a/pkg/payment_request/service_param_value_lookups/service_param_value_lookups_test.go b/pkg/payment_request/service_param_value_lookups/service_param_value_lookups_test.go index d9b197d44d5..34d4a025d2b 100644 --- a/pkg/payment_request/service_param_value_lookups/service_param_value_lookups_test.go +++ b/pkg/payment_request/service_param_value_lookups/service_param_value_lookups_test.go @@ -172,7 +172,7 @@ func (suite *ServiceParamValueLookupsSuite) setupTestMTOServiceItemWithEstimated // i don't think this function gets called for PPMs, but need to verify //paramLookup, err := ServiceParamLookupInitialize(suite.AppContextForTest(), suite.planner, mtoServiceItem, paymentRequest.ID, paymentRequest.MoveTaskOrderID, nil) //suite.FatalNoError(err) - paramLookup := NewServiceItemParamKeyData(suite.planner, serviceItemLookups, mtoServiceItem, mtoShipment, testdatagen.DefaultContractCode) + paramLookup := NewServiceItemParamKeyData(suite.planner, serviceItemLookups, mtoServiceItem, mtoShipment, testdatagen.DefaultContractCode, uuid.Nil) return mtoServiceItem, paymentRequest, ¶mLookup } diff --git a/pkg/services/ghc_rate_engine.go b/pkg/services/ghc_rate_engine.go index 2aa23954ce6..d1c7b923118 100644 --- a/pkg/services/ghc_rate_engine.go +++ b/pkg/services/ghc_rate_engine.go @@ -280,3 +280,35 @@ type IntlPortFuelSurchargePricer interface { Price(appCtx appcontext.AppContext, actualPickupDate time.Time, distance unit.Miles, weight unit.Pound, fscWeightBasedDistanceMultiplier float64, eiaFuelPrice unit.Millicents) (unit.Cents, PricingDisplayParams, error) ParamsPricer } + +// IntlOriginFirstDaySITPricer prices international origin first day SIT +// +//go:generate mockery --name IntlOriginFirstDaySITPricer +type IntlOriginFirstDaySITPricer interface { + Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, weight unit.Pound, perUnitCents int) (unit.Cents, PricingDisplayParams, error) + ParamsPricer +} + +// IntlOriginAdditionalDaySITPricer prices international origin additional days of SIT +// +//go:generate mockery --name IntlOriginAdditionalDaySITPricer +type IntlOriginAdditionalDaySITPricer interface { + Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, numberOfDaysInSIT int, weight unit.Pound, perUnitCents int) (unit.Cents, PricingDisplayParams, error) + ParamsPricer +} + +// IntlDestinationFirstDaySITPricer prices international destination first day SIT +// +//go:generate mockery --name IntlDestinationFirstDaySITPricer +type IntlDestinationFirstDaySITPricer interface { + Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, weight unit.Pound, perUnitCents int) (unit.Cents, PricingDisplayParams, error) + ParamsPricer +} + +// IntlDestinationAdditionalDaySITPricer prices international destination additional days of SIT +// +//go:generate mockery --name IntlDestinationAdditionalDaySITPricer +type IntlDestinationAdditionalDaySITPricer interface { + Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, numberOfDaysInSIT int, weight unit.Pound, perUnitCents int) (unit.Cents, PricingDisplayParams, error) + ParamsPricer +} diff --git a/pkg/services/ghcrateengine/intl_destination_additional_days_sit_pricer.go b/pkg/services/ghcrateengine/intl_destination_additional_days_sit_pricer.go new file mode 100644 index 00000000000..ed57a28b57c --- /dev/null +++ b/pkg/services/ghcrateengine/intl_destination_additional_days_sit_pricer.go @@ -0,0 +1,50 @@ +package ghcrateengine + +import ( + "time" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/unit" +) + +type intlDestinationAdditionalDaySITPricer struct { +} + +func NewIntlDestinationAdditionalDaySITPricer() services.IntlDestinationAdditionalDaySITPricer { + return &intlDestinationAdditionalDaySITPricer{} +} + +func (p intlDestinationAdditionalDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, referenceDate time.Time, numberOfDaysInSIT int, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + return priceIntlAdditionalDaySIT(appCtx, models.ReServiceCodeIDASIT, contractCode, referenceDate, numberOfDaysInSIT, weight, perUnitCents) +} + +func (p intlDestinationAdditionalDaySITPricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + contractCode, err := getParamString(params, models.ServiceItemParamNameContractCode) + if err != nil { + return unit.Cents(0), nil, err + } + + referenceDate, err := getParamTime(params, models.ServiceItemParamNameReferenceDate) + if err != nil { + return unit.Cents(0), nil, err + } + + numberOfDaysInSIT, err := getParamInt(params, models.ServiceItemParamNameNumberDaysSIT) + if err != nil { + return unit.Cents(0), nil, err + } + + perUnitCents, err := getParamInt(params, models.ServiceItemParamNamePerUnitCents) + if err != nil { + return unit.Cents(0), nil, err + } + + weightBilled, err := getParamInt(params, models.ServiceItemParamNameWeightBilled) + if err != nil { + return unit.Cents(0), nil, err + } + + return p.Price(appCtx, contractCode, referenceDate, numberOfDaysInSIT, unit.Pound(weightBilled), perUnitCents) +} diff --git a/pkg/services/ghcrateengine/intl_destination_additional_days_sit_pricer_test.go b/pkg/services/ghcrateengine/intl_destination_additional_days_sit_pricer_test.go new file mode 100644 index 00000000000..76a40753891 --- /dev/null +++ b/pkg/services/ghcrateengine/intl_destination_additional_days_sit_pricer_test.go @@ -0,0 +1,127 @@ +package ghcrateengine + +import ( + "fmt" + "strconv" + "time" + + "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 ( + idasitTestContractYearName = "Base Period Year 1" + idasitTestPerUnitCents = unit.Cents(15000) + idasitTestTotalCost = unit.Cents(1575000) + idasitTestIsPeakPeriod = true + idasitTestEscalationCompounded = 1.0000 + idasitTestWeight = unit.Pound(2100) + idasitTestPriceCents = unit.Cents(500) + idasitNumerDaysInSIT = 5 +) + +var idasitTestRequestedPickupDate = time.Date(testdatagen.TestYear, peakStart.month, peakStart.day, 0, 0, 0, 0, time.UTC) + +func (suite *GHCRateEngineServiceSuite) TestIntlDestinationAdditionalDaySITPricer() { + pricer := NewIntlDestinationAdditionalDaySITPricer() + + suite.Run("success using PaymentServiceItemParams", func() { + paymentServiceItem := suite.setupIntlDestinationAdditionalDayServiceItem() + + totalCost, displayParams, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.NoError(err) + suite.Equal(idasitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: idasitTestContractYearName}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(idasitTestPerUnitCents)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(idasitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(idasitTestEscalationCompounded)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("invalid parameters to PriceUsingParams", func() { + paymentServiceItem := suite.setupIntlDestinationAdditionalDayServiceItem() + + // WeightBilled + paymentServiceItem.PaymentServiceItemParams[4].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNameWeightBilled)) + + // PerUnitCents + paymentServiceItem.PaymentServiceItemParams[3].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNamePerUnitCents)) + + // NumberDaysSIT + paymentServiceItem.PaymentServiceItemParams[2].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNameNumberDaysSIT)) + + // ReferenceDate + paymentServiceItem.PaymentServiceItemParams[1].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a time", models.ServiceItemParamNameReferenceDate)) + + // ContractCode + paymentServiceItem.PaymentServiceItemParams[0].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a string", models.ServiceItemParamNameContractCode)) + }) +} + +func (suite *GHCRateEngineServiceSuite) setupIntlDestinationAdditionalDayServiceItem() models.PaymentServiceItem { + contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + startDate := time.Date(2018, time.January, 1, 12, 0, 0, 0, time.UTC) + endDate := time.Date(2018, time.December, 31, 12, 0, 0, 0, time.UTC) + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + Contract: contract, + ContractID: contract.ID, + StartDate: startDate, + EndDate: endDate, + Escalation: 1.0, + EscalationCompounded: 1.0, + }, + }) + return factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIDASIT, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameContractCode, + KeyType: models.ServiceItemParamTypeString, + Value: contract.Code, + }, + { + Key: models.ServiceItemParamNameReferenceDate, + KeyType: models.ServiceItemParamTypeDate, + Value: idasitTestRequestedPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNameNumberDaysSIT, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idasitNumerDaysInSIT)), + }, + { + Key: models.ServiceItemParamNamePerUnitCents, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idasitTestPerUnitCents)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: strconv.Itoa(idasitTestWeight.Int()), + }, + }, nil, nil, + ) +} diff --git a/pkg/services/ghcrateengine/intl_destination_first_day_sit_pricer.go b/pkg/services/ghcrateengine/intl_destination_first_day_sit_pricer.go new file mode 100644 index 00000000000..eb1354ebbd0 --- /dev/null +++ b/pkg/services/ghcrateengine/intl_destination_first_day_sit_pricer.go @@ -0,0 +1,45 @@ +package ghcrateengine + +import ( + "time" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/unit" +) + +type intlDestinationFirstDaySITPricer struct { +} + +func NewIntlDestinationFirstDaySITPricer() services.IntlDestinationFirstDaySITPricer { + return &intlDestinationFirstDaySITPricer{} +} + +func (p intlDestinationFirstDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, referenceDate time.Time, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + return priceIntlFirstDaySIT(appCtx, models.ReServiceCodeIDFSIT, contractCode, referenceDate, weight, perUnitCents) +} + +func (p intlDestinationFirstDaySITPricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + contractCode, err := getParamString(params, models.ServiceItemParamNameContractCode) + if err != nil { + return unit.Cents(0), nil, err + } + + referenceDate, err := getParamTime(params, models.ServiceItemParamNameReferenceDate) + if err != nil { + return unit.Cents(0), nil, err + } + + perUnitCents, err := getParamInt(params, models.ServiceItemParamNamePerUnitCents) + if err != nil { + return unit.Cents(0), nil, err + } + + weightBilled, err := getParamInt(params, models.ServiceItemParamNameWeightBilled) + if err != nil { + return unit.Cents(0), nil, err + } + + return p.Price(appCtx, contractCode, referenceDate, unit.Pound(weightBilled), perUnitCents) +} diff --git a/pkg/services/ghcrateengine/intl_destination_first_day_sit_pricer_test.go b/pkg/services/ghcrateengine/intl_destination_first_day_sit_pricer_test.go new file mode 100644 index 00000000000..fbaebec2df0 --- /dev/null +++ b/pkg/services/ghcrateengine/intl_destination_first_day_sit_pricer_test.go @@ -0,0 +1,116 @@ +package ghcrateengine + +import ( + "fmt" + "strconv" + "time" + + "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 ( + idfsitTestContractYearName = "Base Period Year 1" + idfsitTestPerUnitCents = unit.Cents(15000) + idfsitTestTotalCost = unit.Cents(315000) + idfsitTestIsPeakPeriod = true + idfsitTestEscalationCompounded = 1.0000 + idfsitTestWeight = unit.Pound(2100) + idfsitTestPriceCents = unit.Cents(500) + idfsitNumerDaysInSIT = 5 +) + +var idfsitTestRequestedPickupDate = time.Date(testdatagen.TestYear, peakStart.month, peakStart.day, 0, 0, 0, 0, time.UTC) + +func (suite *GHCRateEngineServiceSuite) TestIntlDestinationFirstDaySITPricer() { + pricer := NewIntlDestinationFirstDaySITPricer() + + suite.Run("success using PaymentServiceItemParams", func() { + paymentServiceItem := suite.setupIntlDestinationFirstDayServiceItem() + + totalCost, displayParams, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.NoError(err) + suite.Equal(idfsitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: idfsitTestContractYearName}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(idfsitTestPerUnitCents)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(idfsitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(idfsitTestEscalationCompounded)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("invalid parameters to PriceUsingParams", func() { + paymentServiceItem := suite.setupIntlDestinationFirstDayServiceItem() + + // WeightBilled + paymentServiceItem.PaymentServiceItemParams[3].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNameWeightBilled)) + + // PerUnitCents + paymentServiceItem.PaymentServiceItemParams[2].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNamePerUnitCents)) + + // ReferenceDate + paymentServiceItem.PaymentServiceItemParams[1].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a time", models.ServiceItemParamNameReferenceDate)) + + // ContractCode + paymentServiceItem.PaymentServiceItemParams[0].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a string", models.ServiceItemParamNameContractCode)) + }) +} + +func (suite *GHCRateEngineServiceSuite) setupIntlDestinationFirstDayServiceItem() models.PaymentServiceItem { + contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + startDate := time.Date(2018, time.January, 1, 12, 0, 0, 0, time.UTC) + endDate := time.Date(2018, time.December, 31, 12, 0, 0, 0, time.UTC) + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + Contract: contract, + ContractID: contract.ID, + StartDate: startDate, + EndDate: endDate, + Escalation: 1.0, + EscalationCompounded: 1.0, + }, + }) + return factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIDFSIT, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameContractCode, + KeyType: models.ServiceItemParamTypeString, + Value: contract.Code, + }, + { + Key: models.ServiceItemParamNameReferenceDate, + KeyType: models.ServiceItemParamTypeDate, + Value: idfsitTestRequestedPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNamePerUnitCents, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(idfsitTestPerUnitCents)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: strconv.Itoa(idfsitTestWeight.Int()), + }, + }, nil, nil, + ) +} diff --git a/pkg/services/ghcrateengine/intl_origin_additional_days_sit_pricer.go b/pkg/services/ghcrateengine/intl_origin_additional_days_sit_pricer.go new file mode 100644 index 00000000000..e9b4dc22478 --- /dev/null +++ b/pkg/services/ghcrateengine/intl_origin_additional_days_sit_pricer.go @@ -0,0 +1,50 @@ +package ghcrateengine + +import ( + "time" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/unit" +) + +type intlOriginAdditionalDaySITPricer struct { +} + +func NewIntlOriginAdditionalDaySITPricer() services.IntlOriginAdditionalDaySITPricer { + return &intlOriginAdditionalDaySITPricer{} +} + +func (p intlOriginAdditionalDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, referenceDate time.Time, numberOfDaysInSIT int, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + return priceIntlAdditionalDaySIT(appCtx, models.ReServiceCodeIOASIT, contractCode, referenceDate, numberOfDaysInSIT, weight, perUnitCents) +} + +func (p intlOriginAdditionalDaySITPricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + contractCode, err := getParamString(params, models.ServiceItemParamNameContractCode) + if err != nil { + return unit.Cents(0), nil, err + } + + referenceDate, err := getParamTime(params, models.ServiceItemParamNameReferenceDate) + if err != nil { + return unit.Cents(0), nil, err + } + + numberOfDaysInSIT, err := getParamInt(params, models.ServiceItemParamNameNumberDaysSIT) + if err != nil { + return unit.Cents(0), nil, err + } + + perUnitCents, err := getParamInt(params, models.ServiceItemParamNamePerUnitCents) + if err != nil { + return unit.Cents(0), nil, err + } + + weightBilled, err := getParamInt(params, models.ServiceItemParamNameWeightBilled) + if err != nil { + return unit.Cents(0), nil, err + } + + return p.Price(appCtx, contractCode, referenceDate, numberOfDaysInSIT, unit.Pound(weightBilled), perUnitCents) +} diff --git a/pkg/services/ghcrateengine/intl_origin_additional_days_sit_pricer_test.go b/pkg/services/ghcrateengine/intl_origin_additional_days_sit_pricer_test.go new file mode 100644 index 00000000000..87686d5110a --- /dev/null +++ b/pkg/services/ghcrateengine/intl_origin_additional_days_sit_pricer_test.go @@ -0,0 +1,127 @@ +package ghcrateengine + +import ( + "fmt" + "strconv" + "time" + + "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 ( + ioasitTestContractYearName = "Base Period Year 1" + ioasitTestPerUnitCents = unit.Cents(15000) + ioasitTestTotalCost = unit.Cents(1575000) + ioasitTestIsPeakPeriod = true + ioasitTestEscalationCompounded = 1.0000 + ioasitTestWeight = unit.Pound(2100) + ioasitTestPriceCents = unit.Cents(500) + ioasitNumerDaysInSIT = 5 +) + +var ioasitTestRequestedPickupDate = time.Date(testdatagen.TestYear, peakStart.month, peakStart.day, 0, 0, 0, 0, time.UTC) + +func (suite *GHCRateEngineServiceSuite) TestIntlOriginAdditionalDaySITPricer() { + pricer := NewIntlOriginAdditionalDaySITPricer() + + suite.Run("success using PaymentServiceItemParams", func() { + paymentServiceItem := suite.setupIntlOriginAdditionalDayServiceItem() + + totalCost, displayParams, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.NoError(err) + suite.Equal(ioasitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: ioasitTestContractYearName}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(ioasitTestPerUnitCents)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(ioasitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(ioasitTestEscalationCompounded)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("invalid parameters to PriceUsingParams", func() { + paymentServiceItem := suite.setupIntlOriginAdditionalDayServiceItem() + + // WeightBilled + paymentServiceItem.PaymentServiceItemParams[4].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNameWeightBilled)) + + // PerUnitCents + paymentServiceItem.PaymentServiceItemParams[3].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNamePerUnitCents)) + + // NumberDaysSIT + paymentServiceItem.PaymentServiceItemParams[2].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNameNumberDaysSIT)) + + // ReferenceDate + paymentServiceItem.PaymentServiceItemParams[1].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a time", models.ServiceItemParamNameReferenceDate)) + + // ContractCode + paymentServiceItem.PaymentServiceItemParams[0].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a string", models.ServiceItemParamNameContractCode)) + }) +} + +func (suite *GHCRateEngineServiceSuite) setupIntlOriginAdditionalDayServiceItem() models.PaymentServiceItem { + contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + startDate := time.Date(2018, time.January, 1, 12, 0, 0, 0, time.UTC) + endDate := time.Date(2018, time.December, 31, 12, 0, 0, 0, time.UTC) + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + Contract: contract, + ContractID: contract.ID, + StartDate: startDate, + EndDate: endDate, + Escalation: 1.0, + EscalationCompounded: 1.0, + }, + }) + return factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIOASIT, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameContractCode, + KeyType: models.ServiceItemParamTypeString, + Value: contract.Code, + }, + { + Key: models.ServiceItemParamNameReferenceDate, + KeyType: models.ServiceItemParamTypeDate, + Value: ioasitTestRequestedPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNameNumberDaysSIT, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(ioasitNumerDaysInSIT)), + }, + { + Key: models.ServiceItemParamNamePerUnitCents, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(ioasitTestPerUnitCents)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: strconv.Itoa(ioasitTestWeight.Int()), + }, + }, nil, nil, + ) +} diff --git a/pkg/services/ghcrateengine/intl_origin_first_day_sit_pricer.go b/pkg/services/ghcrateengine/intl_origin_first_day_sit_pricer.go new file mode 100644 index 00000000000..2070d13835b --- /dev/null +++ b/pkg/services/ghcrateengine/intl_origin_first_day_sit_pricer.go @@ -0,0 +1,45 @@ +package ghcrateengine + +import ( + "time" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/unit" +) + +type intlOriginFirstDaySITPricer struct { +} + +func NewIntlOriginFirstDaySITPricer() services.IntlOriginFirstDaySITPricer { + return &intlOriginFirstDaySITPricer{} +} + +func (p intlOriginFirstDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, referenceDate time.Time, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + return priceIntlFirstDaySIT(appCtx, models.ReServiceCodeIOFSIT, contractCode, referenceDate, weight, perUnitCents) +} + +func (p intlOriginFirstDaySITPricer) PriceUsingParams(appCtx appcontext.AppContext, params models.PaymentServiceItemParams) (unit.Cents, services.PricingDisplayParams, error) { + contractCode, err := getParamString(params, models.ServiceItemParamNameContractCode) + if err != nil { + return unit.Cents(0), nil, err + } + + referenceDate, err := getParamTime(params, models.ServiceItemParamNameReferenceDate) + if err != nil { + return unit.Cents(0), nil, err + } + + perUnitCents, err := getParamInt(params, models.ServiceItemParamNamePerUnitCents) + if err != nil { + return unit.Cents(0), nil, err + } + + weightBilled, err := getParamInt(params, models.ServiceItemParamNameWeightBilled) + if err != nil { + return unit.Cents(0), nil, err + } + + return p.Price(appCtx, contractCode, referenceDate, unit.Pound(weightBilled), perUnitCents) +} diff --git a/pkg/services/ghcrateengine/intl_origin_first_day_sit_pricer_test.go b/pkg/services/ghcrateengine/intl_origin_first_day_sit_pricer_test.go new file mode 100644 index 00000000000..ae6d9069fc4 --- /dev/null +++ b/pkg/services/ghcrateengine/intl_origin_first_day_sit_pricer_test.go @@ -0,0 +1,116 @@ +package ghcrateengine + +import ( + "fmt" + "strconv" + "time" + + "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 ( + iofsitTestContractYearName = "Base Period Year 1" + iofsitTestPerUnitCents = unit.Cents(15000) + iofsitTestTotalCost = unit.Cents(315000) + iofsitTestIsPeakPeriod = true + iofsitTestEscalationCompounded = 1.0000 + iofsitTestWeight = unit.Pound(2100) + iofsitTestPriceCents = unit.Cents(500) + iofsitNumerDaysInSIT = 5 +) + +var iofsitTestRequestedPickupDate = time.Date(testdatagen.TestYear, peakStart.month, peakStart.day, 0, 0, 0, 0, time.UTC) + +func (suite *GHCRateEngineServiceSuite) TestIntlOriginFirstDaySITPricer() { + pricer := NewIntlOriginFirstDaySITPricer() + + suite.Run("success using PaymentServiceItemParams", func() { + paymentServiceItem := suite.setupIntlOriginFirstDayServiceItem() + + totalCost, displayParams, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.NoError(err) + suite.Equal(iofsitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: iofsitTestContractYearName}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(iofsitTestPerUnitCents)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(iofsitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(iofsitTestEscalationCompounded)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("invalid parameters to PriceUsingParams", func() { + paymentServiceItem := suite.setupIntlOriginFirstDayServiceItem() + + // WeightBilled + paymentServiceItem.PaymentServiceItemParams[3].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err := pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNameWeightBilled)) + + // PerUnitCents + paymentServiceItem.PaymentServiceItemParams[2].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to an int", models.ServiceItemParamNamePerUnitCents)) + + // ReferenceDate + paymentServiceItem.PaymentServiceItemParams[1].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a time", models.ServiceItemParamNameReferenceDate)) + + // ContractCode + paymentServiceItem.PaymentServiceItemParams[0].ServiceItemParamKey.Type = models.ServiceItemParamTypeBoolean + _, _, err = pricer.PriceUsingParams(suite.AppContextForTest(), paymentServiceItem.PaymentServiceItemParams) + suite.Error(err) + suite.Contains(err.Error(), fmt.Sprintf("trying to convert %s to a string", models.ServiceItemParamNameContractCode)) + }) +} + +func (suite *GHCRateEngineServiceSuite) setupIntlOriginFirstDayServiceItem() models.PaymentServiceItem { + contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + startDate := time.Date(2018, time.January, 1, 12, 0, 0, 0, time.UTC) + endDate := time.Date(2018, time.December, 31, 12, 0, 0, 0, time.UTC) + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + Contract: contract, + ContractID: contract.ID, + StartDate: startDate, + EndDate: endDate, + Escalation: 1.0, + EscalationCompounded: 1.0, + }, + }) + return factory.BuildPaymentServiceItemWithParams( + suite.DB(), + models.ReServiceCodeIOFSIT, + []factory.CreatePaymentServiceItemParams{ + { + Key: models.ServiceItemParamNameContractCode, + KeyType: models.ServiceItemParamTypeString, + Value: contract.Code, + }, + { + Key: models.ServiceItemParamNameReferenceDate, + KeyType: models.ServiceItemParamTypeDate, + Value: iofsitTestRequestedPickupDate.Format(DateParamFormat), + }, + { + Key: models.ServiceItemParamNamePerUnitCents, + KeyType: models.ServiceItemParamTypeInteger, + Value: fmt.Sprintf("%d", int(iofsitTestPerUnitCents)), + }, + { + Key: models.ServiceItemParamNameWeightBilled, + KeyType: models.ServiceItemParamTypeInteger, + Value: strconv.Itoa(iofsitTestWeight.Int()), + }, + }, nil, nil, + ) +} diff --git a/pkg/services/ghcrateengine/pricer_helpers_intl.go b/pkg/services/ghcrateengine/pricer_helpers_intl.go index 7d8d70508ea..9754f552b3d 100644 --- a/pkg/services/ghcrateengine/pricer_helpers_intl.go +++ b/pkg/services/ghcrateengine/pricer_helpers_intl.go @@ -114,3 +114,111 @@ func priceIntlPackUnpack(appCtx appcontext.AppContext, packUnpackCode models.ReS return totalCost, displayParams, nil } + +func priceIntlFirstDaySIT(appCtx appcontext.AppContext, firstDaySITCode models.ReServiceCode, contractCode string, referenceDate time.Time, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + if firstDaySITCode != models.ReServiceCodeIOFSIT && firstDaySITCode != models.ReServiceCodeIDFSIT { + return 0, nil, fmt.Errorf("unsupported first day SIT code of %s", firstDaySITCode) + } + if len(contractCode) == 0 { + return 0, nil, errors.New("ContractCode is required") + } + if referenceDate.IsZero() { + return 0, nil, errors.New("ReferenceDate is required") + } + if perUnitCents == 0 { + return 0, nil, errors.New("PerUnitCents is required") + } + + isPeakPeriod := IsPeakPeriod(referenceDate) + + contract, err := fetchContractByContractCode(appCtx, contractCode) + if err != nil { + return 0, nil, fmt.Errorf("could not find contract with code: %s: %w", contractCode, err) + } + + basePrice := float64(perUnitCents) + escalatedPrice, contractYear, err := escalatePriceForContractYear(appCtx, contract.ID, referenceDate, false, basePrice) + if err != nil { + return 0, nil, fmt.Errorf("could not calculate escalated price: %w", err) + } + + escalatedPrice = escalatedPrice * weight.ToCWTFloat64() + totalCost := unit.Cents(math.Round(escalatedPrice)) + + displayParams := services.PricingDisplayParams{ + { + Key: models.ServiceItemParamNameContractYearName, + Value: contractYear.Name, + }, + { + Key: models.ServiceItemParamNamePriceRateOrFactor, + Value: FormatCents(unit.Cents(perUnitCents)), + }, + { + Key: models.ServiceItemParamNameIsPeak, + Value: FormatBool(isPeakPeriod), + }, + { + Key: models.ServiceItemParamNameEscalationCompounded, + Value: FormatEscalation(contractYear.EscalationCompounded), + }, + } + + return totalCost, displayParams, nil +} + +func priceIntlAdditionalDaySIT(appCtx appcontext.AppContext, additionalDaySITCode models.ReServiceCode, contractCode string, referenceDate time.Time, numberOfDaysInSIT int, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + if additionalDaySITCode != models.ReServiceCodeIOASIT && additionalDaySITCode != models.ReServiceCodeIDASIT { + return 0, nil, fmt.Errorf("unsupported additional day SIT code of %s", additionalDaySITCode) + } + if len(contractCode) == 0 { + return 0, nil, errors.New("ContractCode is required") + } + if referenceDate.IsZero() { + return 0, nil, errors.New("ReferenceDate is required") + } + if numberOfDaysInSIT == 0 { + return 0, nil, errors.New("NumberDaysSIT is required") + } + if perUnitCents == 0 { + return 0, nil, errors.New("PerUnitCents is required") + } + + isPeakPeriod := IsPeakPeriod(referenceDate) + + contract, err := fetchContractByContractCode(appCtx, contractCode) + if err != nil { + return 0, nil, fmt.Errorf("could not find contract with code: %s: %w", contractCode, err) + } + + basePrice := float64(perUnitCents) + escalatedPrice, contractYear, err := escalatePriceForContractYear(appCtx, contract.ID, referenceDate, false, basePrice) + if err != nil { + return 0, nil, fmt.Errorf("could not calculate escalated price: %w", err) + } + + escalatedPrice = escalatedPrice * weight.ToCWTFloat64() + totalForNumberOfDaysPrice := escalatedPrice * float64(numberOfDaysInSIT) + totalCost := unit.Cents(math.Round(totalForNumberOfDaysPrice)) + + displayParams := services.PricingDisplayParams{ + { + Key: models.ServiceItemParamNameContractYearName, + Value: contractYear.Name, + }, + { + Key: models.ServiceItemParamNamePriceRateOrFactor, + Value: FormatCents(unit.Cents(perUnitCents)), + }, + { + Key: models.ServiceItemParamNameIsPeak, + Value: FormatBool(isPeakPeriod), + }, + { + Key: models.ServiceItemParamNameEscalationCompounded, + Value: FormatEscalation(contractYear.EscalationCompounded), + }, + } + + return totalCost, displayParams, nil +} diff --git a/pkg/services/ghcrateengine/pricer_helpers_intl_test.go b/pkg/services/ghcrateengine/pricer_helpers_intl_test.go index 56d5bcce1dc..14e3d6c8618 100644 --- a/pkg/services/ghcrateengine/pricer_helpers_intl_test.go +++ b/pkg/services/ghcrateengine/pricer_helpers_intl_test.go @@ -99,3 +99,109 @@ func (suite *GHCRateEngineServiceSuite) TestPriceIntlPackUnpack() { }) } + +func (suite *GHCRateEngineServiceSuite) TestPriceIntlFirstDaySIT() { + suite.Run("success with IDFSIT", func() { + suite.setupIntlDestinationFirstDayServiceItem() + totalCost, displayParams, err := priceIntlFirstDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDFSIT, testdatagen.DefaultContractCode, idfsitTestRequestedPickupDate, idfsitTestWeight, idfsitTestPerUnitCents.Int()) + suite.NoError(err) + suite.Equal(idfsitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: idfsitTestContractYearName}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(idfsitTestEscalationCompounded)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(idfsitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(idfsitTestPerUnitCents)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("success with IOFSIT", func() { + suite.setupIntlOriginFirstDayServiceItem() + totalCost, displayParams, err := priceIntlFirstDaySIT(suite.AppContextForTest(), models.ReServiceCodeIOFSIT, testdatagen.DefaultContractCode, iofsitTestRequestedPickupDate, iofsitTestWeight, iofsitTestPerUnitCents.Int()) + suite.NoError(err) + suite.Equal(iofsitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: iofsitTestContractYearName}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(iofsitTestEscalationCompounded)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(iofsitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(iofsitTestPerUnitCents)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("Invalid parameters to Price", func() { + suite.setupIntlDestinationFirstDayServiceItem() + _, _, err := priceIntlFirstDaySIT(suite.AppContextForTest(), models.ReServiceCodeDLH, testdatagen.DefaultContractCode, idfsitTestRequestedPickupDate, idfsitTestWeight, idfsitTestPerUnitCents.Int()) + suite.Error(err) + suite.Contains(err.Error(), "unsupported first day SIT code") + + _, _, err = priceIntlFirstDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDFSIT, "", idfsitTestRequestedPickupDate, idfsitTestWeight, idfsitTestPerUnitCents.Int()) + suite.Error(err) + suite.Contains(err.Error(), "ContractCode is required") + + _, _, err = priceIntlFirstDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDFSIT, testdatagen.DefaultContractCode, time.Time{}, idfsitTestWeight, idfsitTestPerUnitCents.Int()) + suite.Error(err) + suite.Contains(err.Error(), "ReferenceDate is required") + + _, _, err = priceIntlFirstDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDFSIT, testdatagen.DefaultContractCode, idfsitTestRequestedPickupDate, idfsitTestWeight, 0) + suite.Error(err) + suite.Contains(err.Error(), "PerUnitCents is required") + }) +} + +func (suite *GHCRateEngineServiceSuite) TestPriceIntlAdditionalDaySIT() { + suite.Run("success with IDASIT", func() { + suite.setupIntlDestinationAdditionalDayServiceItem() + totalCost, displayParams, err := priceIntlAdditionalDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDASIT, testdatagen.DefaultContractCode, idasitTestRequestedPickupDate, idasitNumerDaysInSIT, idasitTestWeight, idasitTestPerUnitCents.Int()) + suite.NoError(err) + suite.Equal(idasitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: idasitTestContractYearName}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(idasitTestEscalationCompounded)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(idasitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(idasitTestPerUnitCents)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("success with IOASIT", func() { + suite.setupIntlOriginAdditionalDayServiceItem() + totalCost, displayParams, err := priceIntlAdditionalDaySIT(suite.AppContextForTest(), models.ReServiceCodeIOASIT, testdatagen.DefaultContractCode, ioasitTestRequestedPickupDate, idasitNumerDaysInSIT, ioasitTestWeight, ioasitTestPerUnitCents.Int()) + suite.NoError(err) + suite.Equal(ioasitTestTotalCost, totalCost) + + expectedParams := services.PricingDisplayParams{ + {Key: models.ServiceItemParamNameContractYearName, Value: ioasitTestContractYearName}, + {Key: models.ServiceItemParamNameEscalationCompounded, Value: FormatEscalation(ioasitTestEscalationCompounded)}, + {Key: models.ServiceItemParamNameIsPeak, Value: FormatBool(ioasitTestIsPeakPeriod)}, + {Key: models.ServiceItemParamNamePriceRateOrFactor, Value: FormatCents(ioasitTestPerUnitCents)}, + } + suite.validatePricerCreatedParams(expectedParams, displayParams) + }) + + suite.Run("Invalid parameters to Price", func() { + suite.setupIntlDestinationAdditionalDayServiceItem() + _, _, err := priceIntlAdditionalDaySIT(suite.AppContextForTest(), models.ReServiceCodeDLH, testdatagen.DefaultContractCode, idasitTestRequestedPickupDate, idasitNumerDaysInSIT, idasitTestWeight, idasitTestPerUnitCents.Int()) + suite.Error(err) + suite.Contains(err.Error(), "unsupported additional day SIT code") + + _, _, err = priceIntlAdditionalDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDASIT, "", idasitTestRequestedPickupDate, idasitNumerDaysInSIT, idasitTestWeight, idasitTestPerUnitCents.Int()) + suite.Error(err) + suite.Contains(err.Error(), "ContractCode is required") + + _, _, err = priceIntlAdditionalDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDASIT, testdatagen.DefaultContractCode, time.Time{}, idasitNumerDaysInSIT, idasitTestWeight, idasitTestPerUnitCents.Int()) + suite.Error(err) + suite.Contains(err.Error(), "ReferenceDate is required") + + _, _, err = priceIntlAdditionalDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDASIT, testdatagen.DefaultContractCode, idasitTestRequestedPickupDate, idasitNumerDaysInSIT, idasitTestWeight, 0) + suite.Error(err) + suite.Contains(err.Error(), "PerUnitCents is required") + + _, _, err = priceIntlAdditionalDaySIT(suite.AppContextForTest(), models.ReServiceCodeIDASIT, testdatagen.DefaultContractCode, idasitTestRequestedPickupDate, 0, idasitTestWeight, 0) + suite.Error(err) + suite.Contains(err.Error(), "NumberDaysSIT is required") + }) +} diff --git a/pkg/services/ghcrateengine/service_item_pricer.go b/pkg/services/ghcrateengine/service_item_pricer.go index 130c137c7c6..777ca2283bf 100644 --- a/pkg/services/ghcrateengine/service_item_pricer.go +++ b/pkg/services/ghcrateengine/service_item_pricer.go @@ -107,6 +107,14 @@ func PricerForServiceItem(serviceCode models.ReServiceCode) (services.ParamsPric return NewPortFuelSurchargePricer(), nil case models.ReServiceCodePODFSC: return NewPortFuelSurchargePricer(), nil + case models.ReServiceCodeIOFSIT: + return NewIntlOriginFirstDaySITPricer(), nil + case models.ReServiceCodeIOASIT: + return NewIntlOriginAdditionalDaySITPricer(), nil + case models.ReServiceCodeIDFSIT: + return NewIntlDestinationFirstDaySITPricer(), nil + case models.ReServiceCodeIDASIT: + return NewIntlDestinationAdditionalDaySITPricer(), nil default: // TODO: We may want a different error type here after all pricers have been implemented return nil, apperror.NewNotImplementedError(fmt.Sprintf("pricer not found for code %s", serviceCode)) diff --git a/pkg/services/mocks/IntlDestinationAdditionalDaySITPricer.go b/pkg/services/mocks/IntlDestinationAdditionalDaySITPricer.go new file mode 100644 index 00000000000..7a11b759d7f --- /dev/null +++ b/pkg/services/mocks/IntlDestinationAdditionalDaySITPricer.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" +) + +// IntlDestinationAdditionalDaySITPricer is an autogenerated mock type for the IntlDestinationAdditionalDaySITPricer type +type IntlDestinationAdditionalDaySITPricer struct { + mock.Mock +} + +// Price provides a mock function with given fields: appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents +func (_m *IntlDestinationAdditionalDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, numberOfDaysInSIT int, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + + 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, string, time.Time, int, unit.Pound, int) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, string, time.Time, int, unit.Pound, int) unit.Cents); ok { + r0 = rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, string, time.Time, int, unit.Pound, int) services.PricingDisplayParams); ok { + r1 = rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, string, time.Time, int, unit.Pound, int) error); ok { + r2 = rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// PriceUsingParams provides a mock function with given fields: appCtx, params +func (_m *IntlDestinationAdditionalDaySITPricer) 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 +} + +// NewIntlDestinationAdditionalDaySITPricer creates a new instance of IntlDestinationAdditionalDaySITPricer. 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 NewIntlDestinationAdditionalDaySITPricer(t interface { + mock.TestingT + Cleanup(func()) +}) *IntlDestinationAdditionalDaySITPricer { + mock := &IntlDestinationAdditionalDaySITPricer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/mocks/IntlDestinationFirstDaySITPricer.go b/pkg/services/mocks/IntlDestinationFirstDaySITPricer.go new file mode 100644 index 00000000000..99df36da131 --- /dev/null +++ b/pkg/services/mocks/IntlDestinationFirstDaySITPricer.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" +) + +// IntlDestinationFirstDaySITPricer is an autogenerated mock type for the IntlDestinationFirstDaySITPricer type +type IntlDestinationFirstDaySITPricer struct { + mock.Mock +} + +// Price provides a mock function with given fields: appCtx, contractCode, requestedPickupDate, weight, perUnitCents +func (_m *IntlDestinationFirstDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + + 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, string, time.Time, unit.Pound, int) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, string, time.Time, unit.Pound, int) unit.Cents); ok { + r0 = rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, string, time.Time, unit.Pound, int) services.PricingDisplayParams); ok { + r1 = rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, string, time.Time, unit.Pound, int) error); ok { + r2 = rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// PriceUsingParams provides a mock function with given fields: appCtx, params +func (_m *IntlDestinationFirstDaySITPricer) 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 +} + +// NewIntlDestinationFirstDaySITPricer creates a new instance of IntlDestinationFirstDaySITPricer. 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 NewIntlDestinationFirstDaySITPricer(t interface { + mock.TestingT + Cleanup(func()) +}) *IntlDestinationFirstDaySITPricer { + mock := &IntlDestinationFirstDaySITPricer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/mocks/IntlOriginAdditionalDaySITPricer.go b/pkg/services/mocks/IntlOriginAdditionalDaySITPricer.go new file mode 100644 index 00000000000..a931b2d3879 --- /dev/null +++ b/pkg/services/mocks/IntlOriginAdditionalDaySITPricer.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" +) + +// IntlOriginAdditionalDaySITPricer is an autogenerated mock type for the IntlOriginAdditionalDaySITPricer type +type IntlOriginAdditionalDaySITPricer struct { + mock.Mock +} + +// Price provides a mock function with given fields: appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents +func (_m *IntlOriginAdditionalDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, numberOfDaysInSIT int, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + + 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, string, time.Time, int, unit.Pound, int) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, string, time.Time, int, unit.Pound, int) unit.Cents); ok { + r0 = rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, string, time.Time, int, unit.Pound, int) services.PricingDisplayParams); ok { + r1 = rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, string, time.Time, int, unit.Pound, int) error); ok { + r2 = rf(appCtx, contractCode, requestedPickupDate, numberOfDaysInSIT, weight, perUnitCents) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// PriceUsingParams provides a mock function with given fields: appCtx, params +func (_m *IntlOriginAdditionalDaySITPricer) 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 +} + +// NewIntlOriginAdditionalDaySITPricer creates a new instance of IntlOriginAdditionalDaySITPricer. 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 NewIntlOriginAdditionalDaySITPricer(t interface { + mock.TestingT + Cleanup(func()) +}) *IntlOriginAdditionalDaySITPricer { + mock := &IntlOriginAdditionalDaySITPricer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/mocks/IntlOriginFirstDaySITPricer.go b/pkg/services/mocks/IntlOriginFirstDaySITPricer.go new file mode 100644 index 00000000000..d36cb1e92c9 --- /dev/null +++ b/pkg/services/mocks/IntlOriginFirstDaySITPricer.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" +) + +// IntlOriginFirstDaySITPricer is an autogenerated mock type for the IntlOriginFirstDaySITPricer type +type IntlOriginFirstDaySITPricer struct { + mock.Mock +} + +// Price provides a mock function with given fields: appCtx, contractCode, requestedPickupDate, weight, perUnitCents +func (_m *IntlOriginFirstDaySITPricer) Price(appCtx appcontext.AppContext, contractCode string, requestedPickupDate time.Time, weight unit.Pound, perUnitCents int) (unit.Cents, services.PricingDisplayParams, error) { + ret := _m.Called(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + + 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, string, time.Time, unit.Pound, int) (unit.Cents, services.PricingDisplayParams, error)); ok { + return rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, string, time.Time, unit.Pound, int) unit.Cents); ok { + r0 = rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } else { + r0 = ret.Get(0).(unit.Cents) + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, string, time.Time, unit.Pound, int) services.PricingDisplayParams); ok { + r1 = rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(services.PricingDisplayParams) + } + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, string, time.Time, unit.Pound, int) error); ok { + r2 = rf(appCtx, contractCode, requestedPickupDate, weight, perUnitCents) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// PriceUsingParams provides a mock function with given fields: appCtx, params +func (_m *IntlOriginFirstDaySITPricer) 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 +} + +// NewIntlOriginFirstDaySITPricer creates a new instance of IntlOriginFirstDaySITPricer. 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 NewIntlOriginFirstDaySITPricer(t interface { + mock.TestingT + Cleanup(func()) +}) *IntlOriginFirstDaySITPricer { + mock := &IntlOriginFirstDaySITPricer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/mto_shipment/mto_shipment_updater.go b/pkg/services/mto_shipment/mto_shipment_updater.go index 1f3696da161..fb75c795a77 100644 --- a/pkg/services/mto_shipment/mto_shipment_updater.go +++ b/pkg/services/mto_shipment/mto_shipment_updater.go @@ -844,7 +844,9 @@ func (f *mtoShipmentUpdater) updateShipmentRecord(appCtx appcontext.AppContext, } // when populating the market_code column, it is considered domestic if both pickup & dest are CONUS addresses - newShipment = models.DetermineShipmentMarketCode(newShipment) + if newShipment.ShipmentType != models.MTOShipmentTypePPM { + newShipment = models.DetermineShipmentMarketCode(newShipment) + } if err := txnAppCtx.DB().Update(newShipment); err != nil { return err diff --git a/pkg/services/ppm_closeout/ppm_closeout.go b/pkg/services/ppm_closeout/ppm_closeout.go index 925385d079b..7ced6a8c257 100644 --- a/pkg/services/ppm_closeout/ppm_closeout.go +++ b/pkg/services/ppm_closeout/ppm_closeout.go @@ -36,6 +36,9 @@ type serviceItemPrices struct { haulPrice *unit.Cents haulFSC *unit.Cents haulType models.HaulType + intlPackPrice *unit.Cents + intlUnpackPrice *unit.Cents + intlLinehaulPrice *unit.Cents } func NewPPMCloseoutFetcher(planner route.Planner, paymentRequestHelper paymentrequesthelper.Helper, estimator services.PPMEstimator) services.PPMCloseoutFetcher { @@ -60,11 +63,6 @@ func (p *ppmCloseoutFetcher) GetPPMCloseout(appCtx appcontext.AppContext, ppmShi proGearWeightCustomer, proGearWeightSpouse := p.GetProGearWeights(*ppmShipment) - serviceItems, err := p.getServiceItemPrices(appCtx, *ppmShipment) - if err != nil { - return nil, err - } - var remainingIncentive unit.Cents // Most moves generated by `make db_dev_e2e_populate` skip the part that generates the FinalIncentive for the move, so just return 0. // Moves created through the UI shouldn't be able to skip this part @@ -96,6 +94,11 @@ func (p *ppmCloseoutFetcher) GetPPMCloseout(appCtx appcontext.AppContext, ppmShi fullWeightGCCShipment.ProGearWeight = &proGearCustomerMax fullWeightGCCShipment.SpouseProGearWeight = &proGearSpouseMax gcc, _ := p.calculateGCC(appCtx, *fullWeightGCCShipment, fullAllowableWeight) + + serviceItems, err := p.getServiceItemPrices(appCtx, *ppmShipment) + if err != nil { + return nil, err + } if serviceItems.storageReimbursementCosts != nil { gcc = gcc.AddCents(*serviceItems.storageReimbursementCosts) } @@ -114,11 +117,14 @@ func (p *ppmCloseoutFetcher) GetPPMCloseout(appCtx appcontext.AppContext, ppmShi ppmCloseoutObj.RemainingIncentive = &remainingIncentive ppmCloseoutObj.HaulPrice = serviceItems.haulPrice ppmCloseoutObj.HaulFSC = serviceItems.haulFSC - ppmCloseoutObj.HaulType = serviceItems.haulType + ppmCloseoutObj.HaulType = &serviceItems.haulType ppmCloseoutObj.DOP = serviceItems.dop ppmCloseoutObj.DDP = serviceItems.ddp ppmCloseoutObj.PackPrice = serviceItems.packPrice ppmCloseoutObj.UnpackPrice = serviceItems.unpackPrice + ppmCloseoutObj.IntlLinehaulPrice = serviceItems.intlLinehaulPrice + ppmCloseoutObj.IntlUnpackPrice = serviceItems.intlUnpackPrice + ppmCloseoutObj.IntlPackPrice = serviceItems.intlPackPrice ppmCloseoutObj.SITReimbursement = serviceItems.storageReimbursementCosts return &ppmCloseoutObj, nil @@ -249,53 +255,6 @@ func (p *ppmCloseoutFetcher) GetExpenseStoragePrice(appCtx appcontext.AppContext return storageExpensePrice, err } -func (p *ppmCloseoutFetcher) GetEntitlement(appCtx appcontext.AppContext, moveID uuid.UUID) (*models.Entitlement, error) { - var moveModel models.Move - err := appCtx.DB().EagerPreload( - "OrdersID", - ).Find(&moveModel, moveID) - - if err != nil { - switch err { - case sql.ErrNoRows: - return nil, apperror.NewNotFoundError(moveID, "while looking for Move") - default: - return nil, apperror.NewQueryError("Move", err, "unable to find Move") - } - } - - var order models.Order - orderID := &moveModel.OrdersID - errOrder := appCtx.DB().EagerPreload( - "EntitlementID", - ).Find(&order, orderID) - - if errOrder != nil { - switch errOrder { - case sql.ErrNoRows: - return nil, apperror.NewNotFoundError(*orderID, "while looking for Order") - default: - return nil, apperror.NewQueryError("Order", errOrder, "unable to find Order") - } - } - - var entitlement models.Entitlement - entitlementID := order.EntitlementID - errEntitlement := appCtx.DB().EagerPreload( - "DBAuthorizedWeight", - ).Find(&entitlement, entitlementID) - - if errEntitlement != nil { - switch errEntitlement { - case sql.ErrNoRows: - return nil, apperror.NewNotFoundError(*entitlementID, "while looking for Entitlement") - default: - return nil, apperror.NewQueryError("Entitlement", errEntitlement, "unable to find Entitlement") - } - } - return &entitlement, nil -} - func paramsForServiceCode(code models.ReServiceCode, serviceParams models.ServiceParams) models.ServiceParams { var serviceItemParams models.ServiceParams for _, serviceParam := range serviceParams { @@ -312,17 +271,13 @@ func (p *ppmCloseoutFetcher) getServiceItemPrices(appCtx appcontext.AppContext, var returnPriceObj serviceItemPrices logger := appCtx.Logger() - err := appCtx.DB().Where("mto_shipment_id = ?", ppmShipment.ShipmentID).All(&serviceItemsToPrice) - if err != nil { - return serviceItemPrices{}, err - } + isInternationalShipment := ppmShipment.Shipment.MarketCode == models.MarketCodeInternational + serviceItemsToPrice = ppmshipment.BaseServiceItems(ppmShipment) - serviceItemsToPrice = ppmshipment.BaseServiceItems(ppmShipment.ShipmentID) - - // Change DLH to DSH if move within same Zip3 actualPickupPostal := *ppmShipment.ActualPickupPostalCode actualDestPostal := *ppmShipment.ActualDestinationPostalCode - if actualPickupPostal[0:3] == actualDestPostal[0:3] { + // Change DLH to DSH if move within same Zip3 (only for domestic shipments - intl uses ISLH) + if !isInternationalShipment && actualPickupPostal[0:3] == actualDestPostal[0:3] { serviceItemsToPrice[0] = models.MTOServiceItem{ReService: models.ReService{Code: models.ReServiceCodeDSH}, MTOShipmentID: &ppmShipment.ShipmentID} } contractDate := ppmShipment.ExpectedDepartureDate @@ -335,10 +290,12 @@ func (p *ppmCloseoutFetcher) getServiceItemPrices(appCtx appcontext.AppContext, if paramErr != nil { return serviceItemPrices{}, paramErr } - var totalPrice, packPrice, unpackPrice, destinationPrice, originPrice, haulPrice, haulFSC unit.Cents + + var totalPrice, packPrice, unpackPrice, destinationPrice, originPrice, haulPrice, haulFSC, intlPackPrice, intlUnpackPrice, intlLinehaulPrice unit.Cents var totalWeight unit.Pound var ppmToMtoShipment models.MTOShipment + // adding all the weight tickets together to get the total weight of the moved PPM if len(ppmShipment.WeightTickets) >= 1 { for _, weightTicket := range ppmShipment.WeightTickets { if weightTicket.Status != nil && *weightTicket.Status == models.PPMDocumentStatusRejected { @@ -373,14 +330,18 @@ func (p *ppmCloseoutFetcher) getServiceItemPrices(appCtx appcontext.AppContext, return serviceItemPrices{}, err } + // combo of domestic & int'l service items validCodes := map[models.ReServiceCode]string{ - models.ReServiceCodeDPK: "DPK", - models.ReServiceCodeDUPK: "DUPK", - models.ReServiceCodeDOP: "DOP", - models.ReServiceCodeDDP: "DDP", - models.ReServiceCodeDSH: "DSH", - models.ReServiceCodeDLH: "DLH", - models.ReServiceCodeFSC: "FSC", + models.ReServiceCodeDPK: "DPK", + models.ReServiceCodeDUPK: "DUPK", + models.ReServiceCodeDOP: "DOP", + models.ReServiceCodeDDP: "DDP", + models.ReServiceCodeDSH: "DSH", + models.ReServiceCodeDLH: "DLH", + models.ReServiceCodeFSC: "FSC", + models.ReServiceCodeISLH: "ISLH", + models.ReServiceCodeIHPK: "IHPK", + models.ReServiceCodeIHUPK: "IHUPK", } // If service item is of a type we need for a specific calculation, get its price @@ -402,11 +363,11 @@ func (p *ppmCloseoutFetcher) getServiceItemPrices(appCtx appcontext.AppContext, serviceItemLookups := serviceparamvaluelookups.InitializeLookups(appCtx, ppmToMtoShipment, serviceItem) // This is the struct that gets passed to every param lookup() method that was initialized above - keyData := serviceparamvaluelookups.NewServiceItemParamKeyData(p.planner, serviceItemLookups, serviceItem, ppmToMtoShipment, contract.Code) + keyData := serviceparamvaluelookups.NewServiceItemParamKeyData(p.planner, serviceItemLookups, serviceItem, ppmToMtoShipment, contract.Code, contract.ID) // The distance value gets saved to the mto shipment model to reduce repeated api calls. var shipmentWithDistance models.MTOShipment - err = appCtx.DB().Find(&shipmentWithDistance, ppmShipment.Shipment.ID) + err = appCtx.DB().Eager("PPMShipment").Find(&shipmentWithDistance, ppmShipment.Shipment.ID) if err != nil { logger.Error("could not find shipment in the database") return serviceItemPrices{}, err @@ -419,7 +380,7 @@ func (p *ppmCloseoutFetcher) getServiceItemPrices(appCtx appcontext.AppContext, for _, param := range paramsForServiceCode(serviceItem.ReService.Code, paramsForServiceItems) { paramKey := param.ServiceItemParamKey // This is where the lookup() method of each service item param is actually evaluated - paramValue, serviceParamErr := keyData.ServiceParamValue(appCtx, paramKey.Key) // Fails with "DistanceZip" param? + paramValue, serviceParamErr := keyData.ServiceParamValue(appCtx, paramKey.Key) if serviceParamErr != nil { logger.Error("could not calculate param value lookup", zap.Error(serviceParamErr)) return serviceItemPrices{}, serviceParamErr @@ -452,6 +413,12 @@ func (p *ppmCloseoutFetcher) getServiceItemPrices(appCtx appcontext.AppContext, totalPrice = totalPrice.AddCents(centsValue) switch serviceItem.ReService.Code { + case models.ReServiceCodeIHPK: // Int'l pack + intlPackPrice += centsValue + case models.ReServiceCodeIHUPK: // Int'l unpack + intlUnpackPrice += centsValue + case models.ReServiceCodeISLH: // Int'l shipping & linehaul + intlLinehaulPrice += centsValue case models.ReServiceCodeDPK: packPrice += centsValue case models.ReServiceCodeDUPK: @@ -488,6 +455,9 @@ func (p *ppmCloseoutFetcher) getServiceItemPrices(appCtx appcontext.AppContext, returnPriceObj.storageReimbursementCosts = &sitCosts returnPriceObj.haulPrice = &haulPrice returnPriceObj.haulFSC = &haulFSC + returnPriceObj.intlLinehaulPrice = &intlLinehaulPrice + returnPriceObj.intlPackPrice = &intlPackPrice + returnPriceObj.intlUnpackPrice = &intlUnpackPrice return returnPriceObj, nil } diff --git a/pkg/services/ppm_closeout/ppm_closeout_test.go b/pkg/services/ppm_closeout/ppm_closeout_test.go index ac86dee3f04..cc0fff6a390 100644 --- a/pkg/services/ppm_closeout/ppm_closeout_test.go +++ b/pkg/services/ppm_closeout/ppm_closeout_test.go @@ -24,7 +24,7 @@ const ( ppmBuildWaitingOnCustomer = "waitingOnCustomer" ) -func (suite *PPMCloseoutSuite) TestPPMShipmentCreator() { +func (suite *PPMCloseoutSuite) TestPPMShipmentCloseout() { // One-time test setup mockedPlanner := &mocks.Planner{} diff --git a/pkg/services/ppmshipment/ppm_estimator.go b/pkg/services/ppmshipment/ppm_estimator.go index e49d7846bbe..cf77b16fb04 100644 --- a/pkg/services/ppmshipment/ppm_estimator.go +++ b/pkg/services/ppmshipment/ppm_estimator.go @@ -203,6 +203,12 @@ func (f *estimatePPM) estimateIncentive(appCtx appcontext.AppContext, oldPPMShip } } + contractDate := newPPMShipment.ExpectedDepartureDate + contract, err := serviceparamvaluelookups.FetchContract(appCtx, contractDate) + if err != nil { + return nil, nil, err + } + calculateSITEstimate := shouldCalculateSITCost(newPPMShipment, &oldPPMShipment) // Clear out any previously calculated SIT estimated costs, if SIT is no longer expected @@ -216,33 +222,66 @@ func (f *estimatePPM) estimateIncentive(appCtx appcontext.AppContext, oldPPMShip return oldPPMShipment.EstimatedIncentive, newPPMShipment.SITEstimatedCost, nil } - contractDate := newPPMShipment.ExpectedDepartureDate - contract, err := serviceparamvaluelookups.FetchContract(appCtx, contractDate) - if err != nil { - return nil, nil, err - } - estimatedIncentive := oldPPMShipment.EstimatedIncentive - if !skipCalculatingEstimatedIncentive { - // Clear out advance and advance requested fields when the estimated incentive is reset. - newPPMShipment.HasRequestedAdvance = nil - newPPMShipment.AdvanceAmountRequested = nil + estimatedSITCost := oldPPMShipment.SITEstimatedCost - estimatedIncentive, err = f.calculatePrice(appCtx, newPPMShipment, 0, contract, false) - if err != nil { - return nil, nil, err + // if the PPM is international, we will use a db func + if newPPMShipment.Shipment.MarketCode != models.MarketCodeInternational { + + if !skipCalculatingEstimatedIncentive { + // Clear out advance and advance requested fields when the estimated incentive is reset. + newPPMShipment.HasRequestedAdvance = nil + newPPMShipment.AdvanceAmountRequested = nil + + estimatedIncentive, err = f.calculatePrice(appCtx, newPPMShipment, 0, contract, false) + if err != nil { + return nil, nil, err + } } - } - estimatedSITCost := oldPPMShipment.SITEstimatedCost - if calculateSITEstimate { - estimatedSITCost, err = CalculateSITCost(appCtx, newPPMShipment, contract) - if err != nil { - return nil, nil, err + if calculateSITEstimate { + estimatedSITCost, err = CalculateSITCost(appCtx, newPPMShipment, contract) + if err != nil { + return nil, nil, err + } + } + + return estimatedIncentive, estimatedSITCost, nil + + } else { + pickupAddress := newPPMShipment.PickupAddress + destinationAddress := newPPMShipment.DestinationAddress + + if !skipCalculatingEstimatedIncentive { + // Clear out advance and advance requested fields when the estimated incentive is reset. + newPPMShipment.HasRequestedAdvance = nil + newPPMShipment.AdvanceAmountRequested = nil + + estimatedIncentive, err = f.CalculateOCONUSIncentive(appCtx, newPPMShipment.ID, *pickupAddress, *destinationAddress, contractDate, newPPMShipment.EstimatedWeight.Int(), true, false, false) + if err != nil { + return nil, nil, fmt.Errorf("failed to calculate estimated PPM incentive: %w", err) + } + } + + if calculateSITEstimate { + var sitAddress models.Address + isOrigin := *newPPMShipment.SITLocation == models.SITLocationTypeOrigin + if isOrigin { + sitAddress = *newPPMShipment.PickupAddress + } else if !isOrigin { + sitAddress = *newPPMShipment.DestinationAddress + } else { + return estimatedIncentive, estimatedSITCost, nil + } + daysInSIT := additionalDaysInSIT(*newPPMShipment.SITEstimatedEntryDate, *newPPMShipment.SITEstimatedDepartureDate) + estimatedSITCost, err = f.CalculateOCONUSSITCosts(appCtx, newPPMShipment.ID, sitAddress.ID, isOrigin, contractDate, newPPMShipment.EstimatedWeight.Int(), daysInSIT) + if err != nil { + return nil, nil, fmt.Errorf("failed to calculate estimated PPM incentive: %w", err) + } } - } - return estimatedIncentive, estimatedSITCost, nil + return estimatedIncentive, estimatedSITCost, nil + } } func (f *estimatePPM) maxIncentive(appCtx appcontext.AppContext, oldPPMShipment models.PPMShipment, newPPMShipment *models.PPMShipment, checks ...ppmShipmentValidator) (*unit.Cents, error) { @@ -261,7 +300,7 @@ func (f *estimatePPM) maxIncentive(appCtx appcontext.AppContext, oldPPMShipment // we have access to the MoveTaskOrderID in the ppmShipment object so we can use that to get the customer's maximum weight entitlement var move models.Move err = appCtx.DB().Q().Eager( - "Orders.Entitlement", + "Orders.Entitlement", "Orders.OriginDutyLocation.Address", "Orders.NewDutyLocation.Address", ).Where("show = TRUE").Find(&move, newPPMShipment.Shipment.MoveTaskOrderID) if err != nil { return nil, apperror.NewNotFoundError(newPPMShipment.ID, " error querying move") @@ -277,14 +316,27 @@ func (f *estimatePPM) maxIncentive(appCtx appcontext.AppContext, oldPPMShipment return nil, err } - // since the max incentive is based off of the authorized weight entitlement and that value CAN change - // we will calculate the max incentive each time it is called - maxIncentive, err := f.calculatePrice(appCtx, newPPMShipment, unit.Pound(*orders.Entitlement.DBAuthorizedWeight), contract, true) - if err != nil { - return nil, err - } + if newPPMShipment.Shipment.MarketCode != models.MarketCodeInternational { - return maxIncentive, nil + // since the max incentive is based off of the authorized weight entitlement and that value CAN change + // we will calculate the max incentive each time it is called + maxIncentive, err := f.calculatePrice(appCtx, newPPMShipment, unit.Pound(*orders.Entitlement.DBAuthorizedWeight), contract, true) + if err != nil { + return nil, err + } + + return maxIncentive, nil + } else { + pickupAddress := orders.OriginDutyLocation.Address + destinationAddress := orders.NewDutyLocation.Address + + maxIncentive, err := f.CalculateOCONUSIncentive(appCtx, newPPMShipment.ID, pickupAddress, destinationAddress, contractDate, *orders.Entitlement.DBAuthorizedWeight, false, false, true) + if err != nil { + return nil, fmt.Errorf("failed to calculate estimated PPM incentive: %w", err) + } + + return maxIncentive, nil + } } func (f *estimatePPM) finalIncentive(appCtx appcontext.AppContext, oldPPMShipment models.PPMShipment, newPPMShipment *models.PPMShipment, checks ...ppmShipmentValidator) (*unit.Cents, error) { @@ -307,32 +359,51 @@ func (f *estimatePPM) finalIncentive(appCtx appcontext.AppContext, oldPPMShipmen newTotalWeight = *newPPMShipment.AllowableWeight } - isMissingInfo := shouldSetFinalIncentiveToNil(newPPMShipment, newTotalWeight) - var skipCalculateFinalIncentive bool - finalIncentive := oldPPMShipment.FinalIncentive + contractDate := newPPMShipment.ExpectedDepartureDate + if newPPMShipment.ActualMoveDate != nil { + contractDate = *newPPMShipment.ActualMoveDate + } + contract, err := serviceparamvaluelookups.FetchContract(appCtx, contractDate) + if err != nil { + return nil, err + } + + if newPPMShipment.Shipment.MarketCode != models.MarketCodeInternational { + isMissingInfo := shouldSetFinalIncentiveToNil(newPPMShipment, newTotalWeight) + var skipCalculateFinalIncentive bool + finalIncentive := oldPPMShipment.FinalIncentive + if !isMissingInfo { + skipCalculateFinalIncentive = shouldSkipCalculatingFinalIncentive(newPPMShipment, &oldPPMShipment, originalTotalWeight, newTotalWeight) + if !skipCalculateFinalIncentive { - if !isMissingInfo { - skipCalculateFinalIncentive = shouldSkipCalculatingFinalIncentive(newPPMShipment, &oldPPMShipment, originalTotalWeight, newTotalWeight) - if !skipCalculateFinalIncentive { - contractDate := newPPMShipment.ExpectedDepartureDate - if newPPMShipment.ActualMoveDate != nil { - contractDate = *newPPMShipment.ActualMoveDate - } - contract, err := serviceparamvaluelookups.FetchContract(appCtx, contractDate) - if err != nil { - return nil, err + finalIncentive, err := f.calculatePrice(appCtx, newPPMShipment, newTotalWeight, contract, false) + if err != nil { + return nil, err + } + return finalIncentive, nil } + } else { + finalIncentive = nil - finalIncentive, err = f.calculatePrice(appCtx, newPPMShipment, newTotalWeight, contract, false) + return finalIncentive, nil + } + + return finalIncentive, nil + } else { + pickupAddress := newPPMShipment.PickupAddress + destinationAddress := newPPMShipment.DestinationAddress + + // we can't calculate actual incentive without the weight + if newTotalWeight != 0 { + finalIncentive, err := f.CalculateOCONUSIncentive(appCtx, newPPMShipment.ID, *pickupAddress, *destinationAddress, contractDate, newTotalWeight.Int(), false, true, false) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to calculate estimated PPM incentive: %w", err) } + return finalIncentive, nil + } else { + return nil, nil } - } else { - finalIncentive = nil } - - return finalIncentive, nil } // SumWeightTickets return the total weight of all weightTickets associated with a PPMShipment, returns 0 if there is no valid weight @@ -372,7 +443,7 @@ func (f estimatePPM) calculatePrice(appCtx appcontext.AppContext, ppmShipment *m logger := appCtx.Logger() zeroTotal := false - serviceItemsToPrice := BaseServiceItems(ppmShipment.ShipmentID) + serviceItemsToPrice := BaseServiceItems(*ppmShipment) var move models.Move err := appCtx.DB().Q().Eager( @@ -462,7 +533,7 @@ func (f estimatePPM) calculatePrice(appCtx appcontext.AppContext, ppmShipment *m serviceItemLookups := serviceparamvaluelookups.InitializeLookups(appCtx, mtoShipment, serviceItem) // This is the struct that gets passed to every param lookup() method that was initialized above - keyData := serviceparamvaluelookups.NewServiceItemParamKeyData(f.planner, serviceItemLookups, serviceItem, mtoShipment, contract.Code) + keyData := serviceparamvaluelookups.NewServiceItemParamKeyData(f.planner, serviceItemLookups, serviceItem, mtoShipment, contract.Code, contract.ID) // The distance value gets saved to the mto shipment model to reduce repeated api calls. var shipmentWithDistance models.MTOShipment @@ -540,7 +611,7 @@ func (f estimatePPM) priceBreakdown(appCtx appcontext.AppContext, ppmShipment *m var unpacking unit.Cents var storage unit.Cents - serviceItemsToPrice := BaseServiceItems(ppmShipment.ShipmentID) + serviceItemsToPrice := BaseServiceItems(*ppmShipment) // Replace linehaul pricer with shorthaul pricer if move is within the same Zip3 var pickupPostal, destPostal string @@ -625,7 +696,7 @@ func (f estimatePPM) priceBreakdown(appCtx appcontext.AppContext, ppmShipment *m serviceItemLookups := serviceparamvaluelookups.InitializeLookups(appCtx, mtoShipment, serviceItem) // This is the struct that gets passed to every param lookup() method that was initialized above - keyData := serviceparamvaluelookups.NewServiceItemParamKeyData(f.planner, serviceItemLookups, serviceItem, mtoShipment, contract.Code) + keyData := serviceparamvaluelookups.NewServiceItemParamKeyData(f.planner, serviceItemLookups, serviceItem, mtoShipment, contract.Code, contract.ID) // The distance value gets saved to the mto shipment model to reduce repeated api calls. var shipmentWithDistance models.MTOShipment @@ -693,40 +764,117 @@ func (f estimatePPM) priceBreakdown(appCtx appcontext.AppContext, ppmShipment *m return linehaul, fuel, origin, dest, packing, unpacking, storage, nil } -func CalculateSITCost(appCtx appcontext.AppContext, ppmShipment *models.PPMShipment, contract models.ReContract) (*unit.Cents, error) { - logger := appCtx.Logger() +// function for calculating incentives for OCONUS PPM shipments +// this uses a db function that takes in values needed to come up with the estimated/actual/max incentives +// this simulates the reimbursement for an iHHG move with ISLH, IHPK, IHUPK, and CONUS portion of FSC +func (f *estimatePPM) CalculateOCONUSIncentive(appCtx appcontext.AppContext, ppmShipmentID uuid.UUID, pickupAddress models.Address, destinationAddress models.Address, moveDate time.Time, weight int, isEstimated bool, isActual bool, isMax bool) (*unit.Cents, error) { + var mileage int + ppmPort, err := models.FetchPortLocationByCode(appCtx.DB(), "4E1") // Tacoma, WA port + if err != nil { + return nil, fmt.Errorf("failed to fetch port location: %w", err) + } + + // check if addresses are OCONUS or CONUS -> this determines how we check mileage to/from the authorized port + isPickupOconus := pickupAddress.IsOconus != nil && *pickupAddress.IsOconus + isDestinationOconus := destinationAddress.IsOconus != nil && *destinationAddress.IsOconus + + switch { + case isPickupOconus && isDestinationOconus: + // OCONUS -> OCONUS, we only reimburse for the CONUS mileage of the PPM + mileage = 0 + case isPickupOconus && !isDestinationOconus: + // OCONUS -> CONUS (port ZIP -> address ZIP) + mileage, err = f.planner.ZipTransitDistance(appCtx, ppmPort.UsPostRegionCity.UsprZipID, destinationAddress.PostalCode, true) + if err != nil { + return nil, fmt.Errorf("failed to calculate OCONUS to CONUS mileage: %w", err) + } + case !isPickupOconus && isDestinationOconus: + // CONUS -> OCONUS (address ZIP -> port ZIP) + mileage, err = f.planner.ZipTransitDistance(appCtx, pickupAddress.PostalCode, ppmPort.UsPostRegionCity.UsprZipID, true) + if err != nil { + return nil, fmt.Errorf("failed to calculate CONUS to OCONUS mileage: %w", err) + } + default: + // covering down on CONUS -> CONUS moves - they should not appear here + return nil, fmt.Errorf("invalid pickup and destination configuration: pickup isOconus=%v, destination isOconus=%v", isPickupOconus, isDestinationOconus) + } + + incentive, err := models.CalculatePPMIncentive(appCtx.DB(), ppmShipmentID, pickupAddress.ID, destinationAddress.ID, moveDate, mileage, weight, isEstimated, isActual, isMax) + if err != nil { + return nil, fmt.Errorf("failed to calculate PPM incentive: %w", err) + } + + return (*unit.Cents)(&incentive.TotalIncentive), nil +} + +func (f *estimatePPM) CalculateOCONUSSITCosts(appCtx appcontext.AppContext, ppmID uuid.UUID, addressID uuid.UUID, isOrigin bool, moveDate time.Time, weight int, sitDays int) (*unit.Cents, error) { + if sitDays <= 0 { + return nil, fmt.Errorf("SIT days must be greater than zero") + } + + if weight <= 0 { + return nil, fmt.Errorf("weight must be greater than zero") + } + sitCosts, err := models.CalculatePPMSITCost(appCtx.DB(), ppmID, addressID, isOrigin, moveDate, weight, sitDays) + if err != nil { + return nil, fmt.Errorf("failed to calculate SIT costs: %w", err) + } + + return (*unit.Cents)(&sitCosts.TotalSITCost), nil +} + +func CalculateSITCost(appCtx appcontext.AppContext, ppmShipment *models.PPMShipment, contract models.ReContract) (*unit.Cents, error) { additionalDaysInSIT := additionalDaysInSIT(*ppmShipment.SITEstimatedEntryDate, *ppmShipment.SITEstimatedDepartureDate) - serviceItemsToPrice := StorageServiceItems(ppmShipment.ShipmentID, *ppmShipment.SITLocation, additionalDaysInSIT) + if ppmShipment.Shipment.MarketCode != models.MarketCodeInternational { + logger := appCtx.Logger() - totalPrice := unit.Cents(0) - for _, serviceItem := range serviceItemsToPrice { - pricer, err := ghcrateengine.PricerForServiceItem(serviceItem.ReService.Code) - if err != nil { - logger.Error("unable to find pricer for service item", zap.Error(err)) - return nil, err + serviceItemsToPrice := StorageServiceItems(*ppmShipment, *ppmShipment.SITLocation, additionalDaysInSIT) + + totalPrice := unit.Cents(0) + for _, serviceItem := range serviceItemsToPrice { + pricer, err := ghcrateengine.PricerForServiceItem(serviceItem.ReService.Code) + if err != nil { + logger.Error("unable to find pricer for service item", zap.Error(err)) + return nil, err + } + + var price *unit.Cents + switch serviceItemPricer := pricer.(type) { + case services.DomesticOriginFirstDaySITPricer, services.DomesticDestinationFirstDaySITPricer: + price, _, err = priceFirstDaySIT(appCtx, serviceItemPricer, serviceItem, ppmShipment, contract) + case services.DomesticOriginAdditionalDaysSITPricer, services.DomesticDestinationAdditionalDaysSITPricer: + price, _, err = priceAdditionalDaySIT(appCtx, serviceItemPricer, serviceItem, ppmShipment, additionalDaysInSIT, contract) + default: + return nil, fmt.Errorf("unknown SIT pricer type found for service item code %s", serviceItem.ReService.Code) + } + + if err != nil { + return nil, err + } + + logger.Debug(fmt.Sprintf("Price of service item %s %d", serviceItem.ReService.Code, *price)) + totalPrice += *price } - var price *unit.Cents - switch serviceItemPricer := pricer.(type) { - case services.DomesticOriginFirstDaySITPricer, services.DomesticDestinationFirstDaySITPricer: - price, _, err = priceFirstDaySIT(appCtx, serviceItemPricer, serviceItem, ppmShipment, contract) - case services.DomesticOriginAdditionalDaysSITPricer, services.DomesticDestinationAdditionalDaysSITPricer: - price, _, err = priceAdditionalDaySIT(appCtx, serviceItemPricer, serviceItem, ppmShipment, additionalDaysInSIT, contract) - default: - return nil, fmt.Errorf("unknown SIT pricer type found for service item code %s", serviceItem.ReService.Code) + return &totalPrice, nil + } else { + var sitAddress models.Address + isOrigin := *ppmShipment.SITLocation == models.SITLocationTypeOrigin + if isOrigin { + sitAddress = *ppmShipment.PickupAddress + } else { + sitAddress = *ppmShipment.DestinationAddress } + contractDate := ppmShipment.ExpectedDepartureDate + totalSITCost, err := models.CalculatePPMSITCost(appCtx.DB(), ppmShipment.ID, sitAddress.ID, isOrigin, contractDate, ppmShipment.SITEstimatedWeight.Int(), additionalDaysInSIT) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to calculate PPM SIT incentive: %w", err) } - - logger.Debug(fmt.Sprintf("Price of service item %s %d", serviceItem.ReService.Code, *price)) - totalPrice += *price + return (*unit.Cents)(&totalSITCost.TotalSITCost), nil } - - return &totalPrice, nil } func CalculateSITCostBreakdown(appCtx appcontext.AppContext, ppmShipment *models.PPMShipment, contract models.ReContract) (*models.PPMSITEstimatedCostInfo, error) { @@ -736,7 +884,7 @@ func CalculateSITCostBreakdown(appCtx appcontext.AppContext, ppmShipment *models additionalDaysInSIT := additionalDaysInSIT(*ppmShipment.SITEstimatedEntryDate, *ppmShipment.SITEstimatedDepartureDate) - serviceItemsToPrice := StorageServiceItems(ppmShipment.ShipmentID, *ppmShipment.SITLocation, additionalDaysInSIT) + serviceItemsToPrice := StorageServiceItems(*ppmShipment, *ppmShipment.SITLocation, additionalDaysInSIT) totalPrice := unit.Cents(0) for _, serviceItem := range serviceItemsToPrice { @@ -750,8 +898,12 @@ func CalculateSITCostBreakdown(appCtx appcontext.AppContext, ppmShipment *models switch serviceItemPricer := pricer.(type) { case services.DomesticOriginFirstDaySITPricer, services.DomesticDestinationFirstDaySITPricer: price, ppmSITEstimatedCostInfoData, err = calculateFirstDaySITCostBreakdown(appCtx, serviceItemPricer, serviceItem, ppmShipment, contract, ppmSITEstimatedCostInfoData, logger) + case services.IntlOriginFirstDaySITPricer, services.IntlDestinationFirstDaySITPricer: + price, ppmSITEstimatedCostInfoData, err = calculateIntlFirstDaySITCostBreakdown(appCtx, serviceItemPricer, serviceItem, ppmShipment, contract, ppmSITEstimatedCostInfoData, logger) case services.DomesticOriginAdditionalDaysSITPricer, services.DomesticDestinationAdditionalDaysSITPricer: price, ppmSITEstimatedCostInfoData, err = calculateAdditionalDaySITCostBreakdown(appCtx, serviceItemPricer, serviceItem, ppmShipment, contract, additionalDaysInSIT, ppmSITEstimatedCostInfoData, logger) + case services.IntlOriginAdditionalDaySITPricer, services.IntlDestinationAdditionalDaySITPricer: + price, ppmSITEstimatedCostInfoData, err = calculateIntlAdditionalDaySITCostBreakdown(appCtx, serviceItemPricer, serviceItem, ppmShipment, contract, additionalDaysInSIT, ppmSITEstimatedCostInfoData, logger) default: return nil, fmt.Errorf("unknown SIT pricer type found for service item code %s", serviceItem.ReService.Code) } @@ -795,6 +947,33 @@ func calculateFirstDaySITCostBreakdown(appCtx appcontext.AppContext, serviceItem return price, ppmSITEstimatedCostInfoData, nil } +func calculateIntlFirstDaySITCostBreakdown(appCtx appcontext.AppContext, serviceItemPricer services.ParamsPricer, serviceItem models.MTOServiceItem, ppmShipment *models.PPMShipment, contract models.ReContract, ppmSITEstimatedCostInfoData *models.PPMSITEstimatedCostInfo, logger *zap.Logger) (*unit.Cents, *models.PPMSITEstimatedCostInfo, error) { + price, priceParams, err := priceFirstDaySIT(appCtx, serviceItemPricer, serviceItem, ppmShipment, contract) + if err != nil { + return nil, nil, err + } + ppmSITEstimatedCostInfoData.PriceFirstDaySIT = price + for _, param := range priceParams { + switch param.Key { + case models.ServiceItemParamNameServiceAreaOrigin: + ppmSITEstimatedCostInfoData.ParamsFirstDaySIT.ServiceAreaOrigin = param.Value + case models.ServiceItemParamNameServiceAreaDest: + ppmSITEstimatedCostInfoData.ParamsFirstDaySIT.ServiceAreaDestination = param.Value + case models.ServiceItemParamNameIsPeak: + ppmSITEstimatedCostInfoData.ParamsFirstDaySIT.IsPeak = param.Value + case models.ServiceItemParamNameContractYearName: + ppmSITEstimatedCostInfoData.ParamsFirstDaySIT.ContractYearName = param.Value + case models.ServiceItemParamNamePriceRateOrFactor: + ppmSITEstimatedCostInfoData.ParamsFirstDaySIT.PriceRateOrFactor = param.Value + case models.ServiceItemParamNameEscalationCompounded: + ppmSITEstimatedCostInfoData.ParamsFirstDaySIT.EscalationCompounded = param.Value + default: + logger.Debug(fmt.Sprintf("Unexpected ServiceItemParam in PPM First Day SIT: %s, %s", param.Key, param.Value)) + } + } + return price, ppmSITEstimatedCostInfoData, nil +} + func calculateAdditionalDaySITCostBreakdown(appCtx appcontext.AppContext, serviceItemPricer services.ParamsPricer, serviceItem models.MTOServiceItem, ppmShipment *models.PPMShipment, contract models.ReContract, additionalDaysInSIT int, ppmSITEstimatedCostInfoData *models.PPMSITEstimatedCostInfo, logger *zap.Logger) (*unit.Cents, *models.PPMSITEstimatedCostInfo, error) { price, priceParams, err := priceAdditionalDaySIT(appCtx, serviceItemPricer, serviceItem, ppmShipment, additionalDaysInSIT, contract) if err != nil { @@ -824,56 +1003,119 @@ func calculateAdditionalDaySITCostBreakdown(appCtx appcontext.AppContext, servic return price, ppmSITEstimatedCostInfoData, nil } -func priceFirstDaySIT(appCtx appcontext.AppContext, pricer services.ParamsPricer, serviceItem models.MTOServiceItem, ppmShipment *models.PPMShipment, contract models.ReContract) (*unit.Cents, services.PricingDisplayParams, error) { - firstDayPricer, ok := pricer.(services.DomesticFirstDaySITPricer) - if !ok { - return nil, nil, errors.New("ppm estimate pricer for SIT service item does not implement the first day pricer interface") - } - - // Need to declare if origin or destination for the serviceAreaLookup, otherwise we already have it - serviceAreaPostalCode := ppmShipment.PickupAddress.PostalCode - serviceAreaKey := models.ServiceItemParamNameServiceAreaOrigin - if serviceItem.ReService.Code == models.ReServiceCodeDDFSIT { - serviceAreaPostalCode = ppmShipment.DestinationAddress.PostalCode - serviceAreaKey = models.ServiceItemParamNameServiceAreaDest - } - - serviceAreaLookup := serviceparamvaluelookups.ServiceAreaLookup{ - Address: models.Address{PostalCode: serviceAreaPostalCode}, - } - serviceArea, err := serviceAreaLookup.ParamValue(appCtx, contract.Code) +func calculateIntlAdditionalDaySITCostBreakdown(appCtx appcontext.AppContext, serviceItemPricer services.ParamsPricer, serviceItem models.MTOServiceItem, ppmShipment *models.PPMShipment, contract models.ReContract, additionalDaysInSIT int, ppmSITEstimatedCostInfoData *models.PPMSITEstimatedCostInfo, logger *zap.Logger) (*unit.Cents, *models.PPMSITEstimatedCostInfo, error) { + price, priceParams, err := priceAdditionalDaySIT(appCtx, serviceItemPricer, serviceItem, ppmShipment, additionalDaysInSIT, contract) if err != nil { return nil, nil, err } - - serviceAreaParam := services.PricingDisplayParam{ - Key: serviceAreaKey, - Value: serviceArea, + ppmSITEstimatedCostInfoData.PriceAdditionalDaySIT = price + for _, param := range priceParams { + switch param.Key { + case models.ServiceItemParamNameServiceAreaOrigin: + ppmSITEstimatedCostInfoData.ParamsAdditionalDaySIT.ServiceAreaOrigin = param.Value + case models.ServiceItemParamNameServiceAreaDest: + ppmSITEstimatedCostInfoData.ParamsAdditionalDaySIT.ServiceAreaDestination = param.Value + case models.ServiceItemParamNameIsPeak: + ppmSITEstimatedCostInfoData.ParamsAdditionalDaySIT.IsPeak = param.Value + case models.ServiceItemParamNameContractYearName: + ppmSITEstimatedCostInfoData.ParamsAdditionalDaySIT.ContractYearName = param.Value + case models.ServiceItemParamNamePriceRateOrFactor: + ppmSITEstimatedCostInfoData.ParamsAdditionalDaySIT.PriceRateOrFactor = param.Value + case models.ServiceItemParamNameEscalationCompounded: + ppmSITEstimatedCostInfoData.ParamsAdditionalDaySIT.EscalationCompounded = param.Value + case models.ServiceItemParamNameNumberDaysSIT: + ppmSITEstimatedCostInfoData.ParamsAdditionalDaySIT.NumberDaysSIT = param.Value + default: + logger.Debug(fmt.Sprintf("Unexpected ServiceItemParam in PPM Additional Day SIT: %s, %s", param.Key, param.Value)) + } } + return price, ppmSITEstimatedCostInfoData, nil +} - // Since this function may be ran before closeout, we need to account for if there's no actual move date yet. - if ppmShipment.ActualMoveDate != nil { - price, pricingParams, err := firstDayPricer.Price(appCtx, contract.Code, *ppmShipment.ActualMoveDate, *ppmShipment.SITEstimatedWeight, serviceArea, true) +func priceFirstDaySIT(appCtx appcontext.AppContext, pricer services.ParamsPricer, serviceItem models.MTOServiceItem, ppmShipment *models.PPMShipment, contract models.ReContract) (*unit.Cents, services.PricingDisplayParams, error) { + if serviceItem.ReService.Code == models.ReServiceCodeIOFSIT || serviceItem.ReService.Code == models.ReServiceCodeIDFSIT { + var addressID uuid.UUID + if serviceItem.ReService.Code == models.ReServiceCodeIOFSIT { + addressID = *ppmShipment.PickupAddressID + } else { + addressID = *ppmShipment.DestinationAddressID + } + reServiceID, _ := models.FetchReServiceByCode(appCtx.DB(), serviceItem.ReService.Code) + intlOtherPrice, _ := models.FetchReIntlOtherPrice(appCtx.DB(), addressID, reServiceID.ID, contract.ID, &ppmShipment.ExpectedDepartureDate) + firstDayPricer, ok := pricer.(services.IntlOriginFirstDaySITPricer) + if !ok { + return nil, nil, errors.New("ppm estimate pricer for SIT service item does not implement the first day pricer interface") + } + if ppmShipment.ActualMoveDate != nil { + price, pricingParams, err := firstDayPricer.Price(appCtx, contract.Code, *ppmShipment.ActualMoveDate, *ppmShipment.SITEstimatedWeight, intlOtherPrice.PerUnitCents.Int()) + if err != nil { + return nil, nil, err + } + + appCtx.Logger().Debug(fmt.Sprintf("Pricing params for first day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) + + return &price, pricingParams, nil + } + + price, pricingParams, err := firstDayPricer.Price(appCtx, contract.Code, ppmShipment.ExpectedDepartureDate, *ppmShipment.SITEstimatedWeight, intlOtherPrice.PerUnitCents.Int()) if err != nil { return nil, nil, err } - pricingParams = append(pricingParams, serviceAreaParam) - appCtx.Logger().Debug(fmt.Sprintf("Pricing params for first day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) return &price, pricingParams, nil - } - price, pricingParams, err := firstDayPricer.Price(appCtx, contract.Code, ppmShipment.ExpectedDepartureDate, *ppmShipment.SITEstimatedWeight, serviceArea, true) - if err != nil { - return nil, nil, err - } + } else { + firstDayPricer, ok := pricer.(services.DomesticFirstDaySITPricer) + if !ok { + return nil, nil, errors.New("ppm estimate pricer for SIT service item does not implement the first day pricer interface") + } + + // Need to declare if origin or destination for the serviceAreaLookup, otherwise we already have it + serviceAreaPostalCode := ppmShipment.PickupAddress.PostalCode + serviceAreaKey := models.ServiceItemParamNameServiceAreaOrigin + if serviceItem.ReService.Code == models.ReServiceCodeDDFSIT { + serviceAreaPostalCode = ppmShipment.DestinationAddress.PostalCode + serviceAreaKey = models.ServiceItemParamNameServiceAreaDest + } + + serviceAreaLookup := serviceparamvaluelookups.ServiceAreaLookup{ + Address: models.Address{PostalCode: serviceAreaPostalCode}, + } + serviceArea, err := serviceAreaLookup.ParamValue(appCtx, contract.Code) + if err != nil { + return nil, nil, err + } + + serviceAreaParam := services.PricingDisplayParam{ + Key: serviceAreaKey, + Value: serviceArea, + } + + // Since this function may be ran before closeout, we need to account for if there's no actual move date yet. + if ppmShipment.ActualMoveDate != nil { + price, pricingParams, err := firstDayPricer.Price(appCtx, contract.Code, *ppmShipment.ActualMoveDate, *ppmShipment.SITEstimatedWeight, serviceArea, true) + if err != nil { + return nil, nil, err + } + + pricingParams = append(pricingParams, serviceAreaParam) + + appCtx.Logger().Debug(fmt.Sprintf("Pricing params for first day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) + + return &price, pricingParams, nil + } + price, pricingParams, err := firstDayPricer.Price(appCtx, contract.Code, ppmShipment.ExpectedDepartureDate, *ppmShipment.SITEstimatedWeight, serviceArea, true) + if err != nil { + return nil, nil, err + } - pricingParams = append(pricingParams, serviceAreaParam) + pricingParams = append(pricingParams, serviceAreaParam) - appCtx.Logger().Debug(fmt.Sprintf("Pricing params for first day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) + appCtx.Logger().Debug(fmt.Sprintf("Pricing params for first day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) - return &price, pricingParams, nil + return &price, pricingParams, nil + } } func additionalDaysInSIT(sitEntryDate time.Time, sitDepartureDate time.Time) int { @@ -887,69 +1129,116 @@ func additionalDaysInSIT(sitEntryDate time.Time, sitDepartureDate time.Time) int } func priceAdditionalDaySIT(appCtx appcontext.AppContext, pricer services.ParamsPricer, serviceItem models.MTOServiceItem, ppmShipment *models.PPMShipment, additionalDaysInSIT int, contract models.ReContract) (*unit.Cents, services.PricingDisplayParams, error) { - additionalDaysPricer, ok := pricer.(services.DomesticAdditionalDaysSITPricer) - if !ok { - return nil, nil, errors.New("ppm estimate pricer for SIT service item does not implement the additional days pricer interface") - } + // international shipment logic + if serviceItem.ReService.Code == models.ReServiceCodeIOASIT || serviceItem.ReService.Code == models.ReServiceCodeIDASIT { + // address we need for the per_unit_cents is dependent on if it's origin/destination SIT + var addressID uuid.UUID + if serviceItem.ReService.Code == models.ReServiceCodeIOASIT { + addressID = *ppmShipment.PickupAddressID + } else { + addressID = *ppmShipment.DestinationAddressID + } - // Need to declare if origin or destination for the serviceAreaLookup, otherwise we already have it - serviceAreaPostalCode := ppmShipment.PickupAddress.PostalCode - serviceAreaKey := models.ServiceItemParamNameServiceAreaOrigin - if serviceItem.ReService.Code == models.ReServiceCodeDDASIT { - serviceAreaPostalCode = ppmShipment.DestinationAddress.PostalCode - serviceAreaKey = models.ServiceItemParamNameServiceAreaDest - } - serviceAreaLookup := serviceparamvaluelookups.ServiceAreaLookup{ - Address: models.Address{PostalCode: serviceAreaPostalCode}, - } + var moveDate time.Time + if ppmShipment.ActualMoveDate != nil { + moveDate = *ppmShipment.ActualMoveDate + } else { + moveDate = ppmShipment.ExpectedDepartureDate + } - serviceArea, err := serviceAreaLookup.ParamValue(appCtx, contract.Code) - if err != nil { - return nil, nil, err - } + reServiceID, _ := models.FetchReServiceByCode(appCtx.DB(), serviceItem.ReService.Code) + intlOtherPrice, _ := models.FetchReIntlOtherPrice(appCtx.DB(), addressID, reServiceID.ID, contract.ID, &moveDate) - serviceAreaParam := services.PricingDisplayParam{ - Key: serviceAreaKey, - Value: serviceArea, - } + sitDaysParam := services.PricingDisplayParam{ + Key: models.ServiceItemParamNameNumberDaysSIT, + Value: strconv.Itoa(additionalDaysInSIT), + } - sitDaysParam := services.PricingDisplayParam{ - Key: models.ServiceItemParamNameNumberDaysSIT, - Value: strconv.Itoa(additionalDaysInSIT), - } + additionalDayPricer, ok := pricer.(services.IntlOriginAdditionalDaySITPricer) + if !ok { + return nil, nil, errors.New("ppm estimate pricer for SIT service item does not implement the first day pricer interface") + } - // Since this function may be ran before closeout, we need to account for if there's no actual move date yet. - if ppmShipment.ActualMoveDate != nil { - price, pricingParams, err := additionalDaysPricer.Price(appCtx, contract.Code, *ppmShipment.ActualMoveDate, *ppmShipment.SITEstimatedWeight, serviceArea, additionalDaysInSIT, true) + price, pricingParams, err := additionalDayPricer.Price(appCtx, contract.Code, moveDate, additionalDaysInSIT, *ppmShipment.SITEstimatedWeight, intlOtherPrice.PerUnitCents.Int()) if err != nil { return nil, nil, err } - pricingParams = append(pricingParams, serviceAreaParam, sitDaysParam) + pricingParams = append(pricingParams, sitDaysParam) appCtx.Logger().Debug(fmt.Sprintf("Pricing params for additional day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) return &price, pricingParams, nil - } - price, pricingParams, err := additionalDaysPricer.Price(appCtx, contract.Code, ppmShipment.ExpectedDepartureDate, *ppmShipment.SITEstimatedWeight, serviceArea, additionalDaysInSIT, true) - if err != nil { - return nil, nil, err - } + } else { + // domestic PPMs + additionalDaysPricer, ok := pricer.(services.DomesticAdditionalDaysSITPricer) + if !ok { + return nil, nil, errors.New("ppm estimate pricer for SIT service item does not implement the additional days pricer interface") + } - pricingParams = append(pricingParams, serviceAreaParam, sitDaysParam) + // Need to declare if origin or destination for the serviceAreaLookup, otherwise we already have it + serviceAreaPostalCode := ppmShipment.PickupAddress.PostalCode + serviceAreaKey := models.ServiceItemParamNameServiceAreaOrigin + if serviceItem.ReService.Code == models.ReServiceCodeDDASIT { + serviceAreaPostalCode = ppmShipment.DestinationAddress.PostalCode + serviceAreaKey = models.ServiceItemParamNameServiceAreaDest + } + serviceAreaLookup := serviceparamvaluelookups.ServiceAreaLookup{ + Address: models.Address{PostalCode: serviceAreaPostalCode}, + } - appCtx.Logger().Debug(fmt.Sprintf("Pricing params for additional day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) + serviceArea, err := serviceAreaLookup.ParamValue(appCtx, contract.Code) + if err != nil { + return nil, nil, err + } + + serviceAreaParam := services.PricingDisplayParam{ + Key: serviceAreaKey, + Value: serviceArea, + } + + sitDaysParam := services.PricingDisplayParam{ + Key: models.ServiceItemParamNameNumberDaysSIT, + Value: strconv.Itoa(additionalDaysInSIT), + } + + // Since this function may be ran before closeout, we need to account for if there's no actual move date yet. + if ppmShipment.ActualMoveDate != nil { + price, pricingParams, err := additionalDaysPricer.Price(appCtx, contract.Code, *ppmShipment.ActualMoveDate, *ppmShipment.SITEstimatedWeight, serviceArea, additionalDaysInSIT, true) + if err != nil { + return nil, nil, err + } - return &price, pricingParams, nil + pricingParams = append(pricingParams, serviceAreaParam, sitDaysParam) + + appCtx.Logger().Debug(fmt.Sprintf("Pricing params for additional day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) + + return &price, pricingParams, nil + } + price, pricingParams, err := additionalDaysPricer.Price(appCtx, contract.Code, ppmShipment.ExpectedDepartureDate, *ppmShipment.SITEstimatedWeight, serviceArea, additionalDaysInSIT, true) + if err != nil { + return nil, nil, err + } + + pricingParams = append(pricingParams, serviceAreaParam, sitDaysParam) + + appCtx.Logger().Debug(fmt.Sprintf("Pricing params for additional day SIT %+v", pricingParams), zap.String("shipmentId", ppmShipment.ShipmentID.String())) + + return &price, pricingParams, nil + } } // mapPPMShipmentEstimatedFields remaps our PPMShipment specific information into the fields where the service param lookups // expect to find them on the MTOShipment model. This is only in-memory and shouldn't get saved to the database. func MapPPMShipmentEstimatedFields(appCtx appcontext.AppContext, ppmShipment models.PPMShipment) (models.MTOShipment, error) { + ppmShipment.Shipment.PPMShipment = &ppmShipment + ppmShipment.Shipment.ShipmentType = models.MTOShipmentTypePPM ppmShipment.Shipment.ActualPickupDate = &ppmShipment.ExpectedDepartureDate ppmShipment.Shipment.RequestedPickupDate = &ppmShipment.ExpectedDepartureDate + ppmShipment.Shipment.PickupAddress = ppmShipment.PickupAddress ppmShipment.Shipment.PickupAddress = &models.Address{PostalCode: ppmShipment.PickupAddress.PostalCode} + ppmShipment.Shipment.DestinationAddress = ppmShipment.DestinationAddress ppmShipment.Shipment.DestinationAddress = &models.Address{PostalCode: ppmShipment.DestinationAddress.PostalCode} ppmShipment.Shipment.PrimeActualWeight = ppmShipment.EstimatedWeight @@ -986,9 +1275,13 @@ func MapPPMShipmentMaxIncentiveFields(appCtx appcontext.AppContext, ppmShipment // expect to find them on the MTOShipment model. This is only in-memory and shouldn't get saved to the database. func MapPPMShipmentFinalFields(ppmShipment models.PPMShipment, totalWeight unit.Pound) models.MTOShipment { + ppmShipment.Shipment.PPMShipment = &ppmShipment + ppmShipment.Shipment.ShipmentType = models.MTOShipmentTypePPM ppmShipment.Shipment.ActualPickupDate = ppmShipment.ActualMoveDate ppmShipment.Shipment.RequestedPickupDate = ppmShipment.ActualMoveDate + ppmShipment.Shipment.PickupAddress = ppmShipment.PickupAddress ppmShipment.Shipment.PickupAddress = &models.Address{PostalCode: *ppmShipment.ActualPickupPostalCode} + ppmShipment.Shipment.DestinationAddress = ppmShipment.DestinationAddress ppmShipment.Shipment.DestinationAddress = &models.Address{PostalCode: *ppmShipment.ActualDestinationPostalCode} ppmShipment.Shipment.PrimeActualWeight = &totalWeight @@ -997,19 +1290,35 @@ func MapPPMShipmentFinalFields(ppmShipment models.PPMShipment, totalWeight unit. // baseServiceItems returns a list of the MTOServiceItems that makeup the price of the estimated incentive. These // are the same non-accesorial service items that get auto-created and approved when the TOO approves an HHG shipment. -func BaseServiceItems(mtoShipmentID uuid.UUID) []models.MTOServiceItem { - return []models.MTOServiceItem{ - {ReService: models.ReService{Code: models.ReServiceCodeDLH}, MTOShipmentID: &mtoShipmentID}, - {ReService: models.ReService{Code: models.ReServiceCodeFSC}, MTOShipmentID: &mtoShipmentID}, - {ReService: models.ReService{Code: models.ReServiceCodeDOP}, MTOShipmentID: &mtoShipmentID}, - {ReService: models.ReService{Code: models.ReServiceCodeDDP}, MTOShipmentID: &mtoShipmentID}, - {ReService: models.ReService{Code: models.ReServiceCodeDPK}, MTOShipmentID: &mtoShipmentID}, - {ReService: models.ReService{Code: models.ReServiceCodeDUPK}, MTOShipmentID: &mtoShipmentID}, +func BaseServiceItems(ppmShipment models.PPMShipment) []models.MTOServiceItem { + mtoShipmentID := ppmShipment.ShipmentID + isInternationalShipment := ppmShipment.Shipment.MarketCode == models.MarketCodeInternational + + if isInternationalShipment { + return []models.MTOServiceItem{ + {ReService: models.ReService{Code: models.ReServiceCodeFSC}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeIHPK}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeIHUPK}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeISLH}, MTOShipmentID: &mtoShipmentID}, + } + } else { + return []models.MTOServiceItem{ + {ReService: models.ReService{Code: models.ReServiceCodeDLH}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeFSC}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeDOP}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeDDP}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeDPK}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeDUPK}, MTOShipmentID: &mtoShipmentID}, + } } } -func StorageServiceItems(mtoShipmentID uuid.UUID, locationType models.SITLocationType, additionalDaysInSIT int) []models.MTOServiceItem { - if locationType == models.SITLocationTypeOrigin { +func StorageServiceItems(ppmShipment models.PPMShipment, locationType models.SITLocationType, additionalDaysInSIT int) []models.MTOServiceItem { + mtoShipmentID := ppmShipment.ShipmentID + isInternationalShipment := ppmShipment.Shipment.MarketCode == models.MarketCodeInternational + + // domestic shipments + if locationType == models.SITLocationTypeOrigin && !isInternationalShipment { if additionalDaysInSIT > 0 { return []models.MTOServiceItem{ {ReService: models.ReService{Code: models.ReServiceCodeDOFSIT}, MTOShipmentID: &mtoShipmentID}, @@ -1020,15 +1329,41 @@ func StorageServiceItems(mtoShipmentID uuid.UUID, locationType models.SITLocatio {ReService: models.ReService{Code: models.ReServiceCodeDOFSIT}, MTOShipmentID: &mtoShipmentID}} } - if additionalDaysInSIT > 0 { + if locationType == models.SITLocationTypeDestination && !isInternationalShipment { + if additionalDaysInSIT > 0 { + return []models.MTOServiceItem{ + {ReService: models.ReService{Code: models.ReServiceCodeDDFSIT}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeDDASIT}, MTOShipmentID: &mtoShipmentID}, + } + } + return []models.MTOServiceItem{ + {ReService: models.ReService{Code: models.ReServiceCodeDDFSIT}, MTOShipmentID: &mtoShipmentID}} + } + + // international shipments + if locationType == models.SITLocationTypeOrigin && isInternationalShipment { + if additionalDaysInSIT > 0 { + return []models.MTOServiceItem{ + {ReService: models.ReService{Code: models.ReServiceCodeIOFSIT}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeIOASIT}, MTOShipmentID: &mtoShipmentID}, + } + } return []models.MTOServiceItem{ - {ReService: models.ReService{Code: models.ReServiceCodeDDFSIT}, MTOShipmentID: &mtoShipmentID}, - {ReService: models.ReService{Code: models.ReServiceCodeDDASIT}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeIOFSIT}, MTOShipmentID: &mtoShipmentID}} + } + + if locationType == models.SITLocationTypeDestination && isInternationalShipment { + if additionalDaysInSIT > 0 { + return []models.MTOServiceItem{ + {ReService: models.ReService{Code: models.ReServiceCodeIDFSIT}, MTOShipmentID: &mtoShipmentID}, + {ReService: models.ReService{Code: models.ReServiceCodeIDASIT}, MTOShipmentID: &mtoShipmentID}, + } } + return []models.MTOServiceItem{ + {ReService: models.ReService{Code: models.ReServiceCodeIDFSIT}, MTOShipmentID: &mtoShipmentID}} } - return []models.MTOServiceItem{ - {ReService: models.ReService{Code: models.ReServiceCodeDDFSIT}, MTOShipmentID: &mtoShipmentID}} + return nil } // paramsForServiceCode filters the list of all service params for service items, to only those matching the service diff --git a/pkg/services/ppmshipment/ppm_estimator_test.go b/pkg/services/ppmshipment/ppm_estimator_test.go index d4ca213d2ac..f384223e3ba 100644 --- a/pkg/services/ppmshipment/ppm_estimator_test.go +++ b/pkg/services/ppmshipment/ppm_estimator_test.go @@ -696,6 +696,7 @@ func (suite *PPMShipmentSuite) TestPPMEstimator() { }, }, }, nil) + setupPricerData() newPPM := oldPPMShipment newPPM.HasProGear = models.BoolPointer(false) @@ -1559,6 +1560,7 @@ func (suite *PPMShipmentSuite) TestPPMEstimator() { }) suite.Run("Final Incentive - does not change when required fields are the same", func() { + setupPricerData() oldPPMShipment := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ { Model: models.PPMShipment{ @@ -1607,6 +1609,7 @@ func (suite *PPMShipmentSuite) TestPPMEstimator() { }) suite.Run("Final Incentive - set to nil when missing info", func() { + setupPricerData() oldPPMShipment := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ { Model: models.PPMShipment{ @@ -1698,7 +1701,7 @@ func (suite *PPMShipmentSuite) TestPPMEstimator() { }, }, { - Model: &models.Address{ + Model: models.Address{ StreetAddress1: "987 Other Avenue", StreetAddress2: models.StringPointer("P.O. Box 1234"), StreetAddress3: models.StringPointer("c/o Another Person"), @@ -1710,7 +1713,7 @@ func (suite *PPMShipmentSuite) TestPPMEstimator() { Type: &factory.Addresses.PickupAddress, }, { - Model: &models.Address{ + Model: models.Address{ StreetAddress1: "987 Other Avenue", StreetAddress2: models.StringPointer("P.O. Box 12345"), StreetAddress3: models.StringPointer("c/o Another Person"), @@ -1760,30 +1763,6 @@ func (suite *PPMShipmentSuite) TestPPMEstimator() { SITEstimatedDepartureDate: &entryDate, }, }, - { - Model: models.Address{ - StreetAddress1: "987 Other Avenue", - StreetAddress2: models.StringPointer("P.O. Box 1234"), - StreetAddress3: models.StringPointer("c/o Another Person"), - City: "Des Moines", - State: "IA", - PostalCode: "50309", - County: models.StringPointer("POLK"), - }, - Type: &factory.Addresses.PickupAddress, - }, - { - Model: models.Address{ - StreetAddress1: "987 Other Avenue", - StreetAddress2: models.StringPointer("P.O. Box 12345"), - StreetAddress3: models.StringPointer("c/o Another Person"), - City: "Fort Eisenhower", - State: "GA", - PostalCode: "50309", - County: models.StringPointer("COLUMBIA"), - }, - Type: &factory.Addresses.DeliveryAddress, - }, { Model: mtoShipment, LinkOnly: true, @@ -2049,3 +2028,623 @@ func (suite *PPMShipmentSuite) TestPPMEstimator() { }) }) } + +func (suite *PPMShipmentSuite) TestInternationalPPMEstimator() { + planner := &mocks.Planner{} + paymentRequestHelper := &prhelpermocks.Helper{} + ppmEstimator := NewEstimatePPM(planner, paymentRequestHelper) + + setupPricerData := func() { + contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + startDate := time.Date(2020, time.January, 1, 12, 0, 0, 0, time.UTC) + endDate := time.Date(2020, time.December, 31, 12, 0, 0, 0, time.UTC) + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + Contract: contract, + ContractID: contract.ID, + StartDate: startDate, + EndDate: endDate, + Escalation: 1.0, + EscalationCompounded: 1.0, + }, + }) + } + + suite.Run("Estimated Incentive", func() { + suite.Run("Estimated Incentive - Success using estimated weight and not db authorized weight for CONUS -> OCONUS", func() { + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + setupPricerData() + + estimatedWeight := unit.Pound(5000) + newPPM := ppm + newPPM.EstimatedWeight = &estimatedWeight + + planner.On("ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "74133", "98421", true).Return(3000, nil) + + ppmEstimate, _, err := ppmEstimator.EstimateIncentiveWithDefaultChecks(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(ppmEstimate) + + // it should've called from the pickup -> port and NOT pickup -> dest + planner.AssertCalled(suite.T(), "ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "74133", "98421", true) + suite.Equal(unit.Cents(459178), *ppmEstimate) + }) + + suite.Run("Estimated Incentive - Success using estimated weight and not db authorized weight for OCONUS -> CONUS", func() { + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.DeliveryAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.PickupAddress, + }, + }, nil) + + setupPricerData() + + estimatedWeight := unit.Pound(5000) + newPPM := ppm + newPPM.EstimatedWeight = &estimatedWeight + + planner.On("ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "98421", "74133", true).Return(3000, nil) + + ppmEstimate, _, err := ppmEstimator.EstimateIncentiveWithDefaultChecks(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(ppmEstimate) + + // it should've called from the pickup -> port and NOT pickup -> dest + planner.AssertCalled(suite.T(), "ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "98421", "74133", true) + suite.Equal(unit.Cents(423178), *ppmEstimate) + }) + }) + + suite.Run("Max Incentive", func() { + suite.Run("Max Incentive - Success using db authorized weight and not estimated for CONUS -> OCONUS", func() { + oconusAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + destDutyLocation := factory.BuildDutyLocation(suite.DB(), []factory.Customization{ + { + Model: models.DutyLocation{ + Name: "Test OCONUS Duty Location", + AddressID: oconusAddress.ID, + }, + }, + }, nil) + order := factory.BuildOrder(suite.DB(), []factory.Customization{ + { + Model: models.Order{ + NewDutyLocationID: destDutyLocation.ID, + }, + }, + }, nil) + // when the PPM shipment is in draft, we use the estimated weight and not the db authorized weight + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.Move{ + OrdersID: order.ID, + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + setupPricerData() + + estimatedWeight := unit.Pound(5000) + newPPM := ppm + newPPM.EstimatedWeight = &estimatedWeight + + // DTOD will be called to get the distance between the origin duty location & the Tacoma Port ZIP + planner.On("ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "50309", "98421", true).Return(3000, nil) + + ppmMaxIncentive, err := ppmEstimator.MaxIncentive(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(ppmMaxIncentive) + + // it should've called from the pickup -> port and NOT pickup -> dest + planner.AssertCalled(suite.T(), "ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "50309", "98421", true) + suite.Equal(unit.Cents(656532), *ppmMaxIncentive) + }) + + suite.Run("Max Incentive - Success using db authorized weight and not estimated for OCONUS -> CONUS", func() { + oconusAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + pickupDutyLocation := factory.BuildDutyLocation(suite.DB(), []factory.Customization{ + { + Model: models.DutyLocation{ + Name: "Test OCONUS Duty Location", + AddressID: oconusAddress.ID, + }, + }, + }, nil) + order := factory.BuildOrder(suite.DB(), []factory.Customization{ + { + Model: models.Order{ + OriginDutyLocationID: &pickupDutyLocation.ID, + }, + }, + }, nil) + // when the PPM shipment is in draft, we use the estimated weight and not the db authorized weight + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.Move{ + OrdersID: order.ID, + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + setupPricerData() + + estimatedWeight := unit.Pound(5000) + newPPM := ppm + newPPM.EstimatedWeight = &estimatedWeight + + // DTOD will be called to get the distance between the origin duty location & the Tacoma Port ZIP + planner.On("ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "98421", "30813", true).Return(3000, nil) + + ppmMaxIncentive, err := ppmEstimator.MaxIncentive(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(ppmMaxIncentive) + + // it should've called from the pickup -> port and NOT pickup -> dest + planner.AssertCalled(suite.T(), "ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "98421", "30813", true) + suite.Equal(unit.Cents(676692), *ppmMaxIncentive) + }) + }) + + suite.Run("Final Incentive", func() { + suite.Run("Final Incentive - Success using estimated weight for CONUS -> OCONUS", func() { + updatedMoveDate := time.Date(2020, time.March, 15, 0, 0, 0, 0, time.UTC) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.PPMShipment{ + ActualMoveDate: models.TimePointer(updatedMoveDate), + Status: models.PPMShipmentStatusWaitingOnCustomer, + EstimatedWeight: models.PoundPointer(4000), + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + newPPM := ppm + newFullWeight := unit.Pound(8000) + newEmptyWeight := unit.Pound(3000) + newPPM.WeightTickets = models.WeightTickets{ + factory.BuildWeightTicket(suite.DB(), []factory.Customization{ + { + Model: models.WeightTicket{ + FullWeight: &newFullWeight, + EmptyWeight: &newEmptyWeight, + }, + }, + }, nil), + } + + setupPricerData() + + planner.On("ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "74133", "98421", true).Return(3000, nil) + + ppmFinalIncentive, err := ppmEstimator.FinalIncentiveWithDefaultChecks(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(ppmFinalIncentive) + + // it should've called from the pickup -> port and NOT pickup -> dest + planner.AssertCalled(suite.T(), "ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "74133", "98421", true) + suite.Equal(unit.Cents(459178), *ppmFinalIncentive) + }) + + suite.Run("Final Incentive - Success using estimated weight for OCONUS -> CONUS", func() { + updatedMoveDate := time.Date(2020, time.March, 15, 0, 0, 0, 0, time.UTC) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.PPMShipment{ + ActualMoveDate: models.TimePointer(updatedMoveDate), + Status: models.PPMShipmentStatusWaitingOnCustomer, + EstimatedWeight: models.PoundPointer(4000), + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.DeliveryAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.PickupAddress, + }, + }, nil) + + newPPM := ppm + newFullWeight := unit.Pound(8000) + newEmptyWeight := unit.Pound(3000) + newPPM.WeightTickets = models.WeightTickets{ + factory.BuildWeightTicket(suite.DB(), []factory.Customization{ + { + Model: models.WeightTicket{ + FullWeight: &newFullWeight, + EmptyWeight: &newEmptyWeight, + }, + }, + }, nil), + } + + setupPricerData() + + planner.On("ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "98421", "74133", true).Return(3000, nil) + + ppmFinalIncentive, err := ppmEstimator.FinalIncentiveWithDefaultChecks(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(ppmFinalIncentive) + + // it should've called from the pickup -> port and NOT pickup -> dest + planner.AssertCalled(suite.T(), "ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), + "98421", "74133", true) + suite.Equal(unit.Cents(423178), *ppmFinalIncentive) + }) + }) + + suite.Run("SIT Costs for OCONUS PPMs", func() { + suite.Run("CalculateSITCost - Success using estimated weight for CONUS -> OCONUS", func() { + originLocation := models.SITLocationTypeOrigin + entryDate := time.Date(2020, time.March, 15, 0, 0, 0, 0, time.UTC) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.PPMShipment{ + EstimatedWeight: models.PoundPointer(4000), + SITExpected: models.BoolPointer(true), + SITLocation: &originLocation, + SITEstimatedWeight: models.PoundPointer(unit.Pound(2000)), + SITEstimatedEntryDate: &entryDate, + SITEstimatedDepartureDate: models.TimePointer(entryDate.Add(time.Hour * 24 * 30)), + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + newPPM := ppm + newEstimatedWeight := models.PoundPointer(5500) + newPPM.SITEstimatedWeight = newEstimatedWeight + setupPricerData() + + _, estimatedSITCost, err := ppmEstimator.EstimateIncentiveWithDefaultChecks(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(estimatedSITCost) + suite.Equal(unit.Cents(24360), *estimatedSITCost) + }) + + suite.Run("CalculateSITCost - Success using estimated weight for CONUS -> OCONUS", func() { + originLocation := models.SITLocationTypeDestination + entryDate := time.Date(2020, time.March, 15, 0, 0, 0, 0, time.UTC) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.PPMShipment{ + EstimatedWeight: models.PoundPointer(4000), + SITExpected: models.BoolPointer(true), + SITLocation: &originLocation, + SITEstimatedWeight: models.PoundPointer(unit.Pound(2000)), + SITEstimatedEntryDate: &entryDate, + SITEstimatedDepartureDate: models.TimePointer(entryDate.Add(time.Hour * 24 * 30)), + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + newPPM := ppm + newEstimatedWeight := models.PoundPointer(5500) + newPPM.SITEstimatedWeight = newEstimatedWeight + setupPricerData() + + _, estimatedSITCost, err := ppmEstimator.EstimateIncentiveWithDefaultChecks(suite.AppContextForTest(), ppm, &newPPM) + suite.NilOrNoVerrs(err) + suite.NotNil(estimatedSITCost) + suite.Equal(unit.Cents(41080), *estimatedSITCost) + }) + + suite.Run("CalculatePPMSITEstimatedCost - Success for OCONUS PPM", func() { + originLocation := models.SITLocationTypeDestination + entryDate := time.Date(2020, time.March, 15, 0, 0, 0, 0, time.UTC) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.PPMShipment{ + EstimatedWeight: models.PoundPointer(4000), + SITExpected: models.BoolPointer(true), + SITLocation: &originLocation, + SITEstimatedWeight: models.PoundPointer(unit.Pound(2000)), + SITEstimatedEntryDate: &entryDate, + SITEstimatedDepartureDate: models.TimePointer(entryDate.Add(time.Hour * 24 * 30)), + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + newPPM := ppm + newEstimatedWeight := models.PoundPointer(5500) + newPPM.SITEstimatedWeight = newEstimatedWeight + setupPricerData() + + estimatedSITCost, err := ppmEstimator.CalculatePPMSITEstimatedCost(suite.AppContextForTest(), &ppm) + suite.NilOrNoVerrs(err) + suite.NotNil(estimatedSITCost) + suite.Equal(unit.Cents(20540), *estimatedSITCost) + }) + + suite.Run("CalculatePPMSITEstimatedCostBreakdown - Success for OCONUS PPM", func() { + originLocation := models.SITLocationTypeDestination + entryDate := time.Date(2020, time.March, 15, 0, 0, 0, 0, time.UTC) + ppm := factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: models.PPMShipment{ + EstimatedWeight: models.PoundPointer(4000), + SITExpected: models.BoolPointer(true), + SITLocation: &originLocation, + SITEstimatedWeight: models.PoundPointer(unit.Pound(2000)), + SITEstimatedEntryDate: &entryDate, + SITEstimatedDepartureDate: models.TimePointer(entryDate.Add(time.Hour * 24 * 30)), + }, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Tulsa", + State: "OK", + PostalCode: "74133", + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "JBER", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.DeliveryAddress, + }, + }, nil) + + newPPM := ppm + newEstimatedWeight := models.PoundPointer(5500) + newPPM.SITEstimatedWeight = newEstimatedWeight + setupPricerData() + + sitCosts, err := ppmEstimator.CalculatePPMSITEstimatedCostBreakdown(suite.AppContextForTest(), &ppm) + suite.NilOrNoVerrs(err) + suite.NotNil(sitCosts) + suite.Equal(unit.Cents(20540), *sitCosts.EstimatedSITCost) + suite.Equal(unit.Cents(12140), *sitCosts.PriceFirstDaySIT) + suite.Equal(unit.Cents(8400), *sitCosts.PriceAdditionalDaySIT) + }) + }) +} diff --git a/pkg/services/ppmshipment/ppm_shipment_updater.go b/pkg/services/ppmshipment/ppm_shipment_updater.go index f8cc99b8d30..c52936e9e9f 100644 --- a/pkg/services/ppmshipment/ppm_shipment_updater.go +++ b/pkg/services/ppmshipment/ppm_shipment_updater.go @@ -117,66 +117,6 @@ func (f *ppmShipmentUpdater) updatePPMShipment(appCtx appcontext.AppContext, ppm } transactionError := appCtx.NewTransaction(func(txnAppCtx appcontext.AppContext) error { - // This potentially updates the MTOShipment.Distance field so include it in the transaction - estimatedIncentive, estimatedSITCost, err := f.estimator.EstimateIncentiveWithDefaultChecks(appCtx, *oldPPMShipment, updatedPPMShipment) - if err != nil { - return err - } - - updatedPPMShipment.EstimatedIncentive = estimatedIncentive - updatedPPMShipment.SITEstimatedCost = estimatedSITCost - - // if the PPM shipment is past closeout then we should not calculate the max incentive, it is already set in stone - if oldPPMShipment.Status != models.PPMShipmentStatusWaitingOnCustomer && - oldPPMShipment.Status != models.PPMShipmentStatusCloseoutComplete && - oldPPMShipment.Status != models.PPMShipmentStatusComplete && - oldPPMShipment.Status != models.PPMShipmentStatusNeedsCloseout { - maxIncentive, err := f.estimator.MaxIncentive(appCtx, *oldPPMShipment, updatedPPMShipment) - if err != nil { - return err - } - updatedPPMShipment.MaxIncentive = maxIncentive - } - - if appCtx.Session() != nil { - if appCtx.Session().IsOfficeUser() { - edited := models.PPMAdvanceStatusEdited - if oldPPMShipment.HasRequestedAdvance != nil && updatedPPMShipment.HasRequestedAdvance != nil { - if !*oldPPMShipment.HasRequestedAdvance && *updatedPPMShipment.HasRequestedAdvance { - updatedPPMShipment.AdvanceStatus = &edited - } else if *oldPPMShipment.HasRequestedAdvance && !*updatedPPMShipment.HasRequestedAdvance { - updatedPPMShipment.AdvanceStatus = &edited - } - } - if oldPPMShipment.AdvanceAmountRequested != nil && updatedPPMShipment.AdvanceAmountRequested != nil { - if *oldPPMShipment.AdvanceAmountRequested != *updatedPPMShipment.AdvanceAmountRequested { - updatedPPMShipment.AdvanceStatus = &edited - } - } - } - if appCtx.Session().IsMilApp() { - if isPrimeCounseled && updatedPPMShipment.HasRequestedAdvance != nil { - received := models.PPMAdvanceStatusReceived - notReceived := models.PPMAdvanceStatusNotReceived - - if updatedPPMShipment.HasReceivedAdvance != nil && *updatedPPMShipment.HasRequestedAdvance { - if *updatedPPMShipment.HasReceivedAdvance { - updatedPPMShipment.AdvanceStatus = &received - } - if !*updatedPPMShipment.HasReceivedAdvance { - updatedPPMShipment.AdvanceStatus = ¬Received - } - } - } - } - } - - finalIncentive, err := f.estimator.FinalIncentiveWithDefaultChecks(appCtx, *oldPPMShipment, updatedPPMShipment) - if err != nil { - return err - } - updatedPPMShipment.FinalIncentive = finalIncentive - if updatedPPMShipment.W2Address != nil { var updatedAddress *models.Address var createOrUpdateErr error @@ -282,6 +222,69 @@ func (f *ppmShipmentUpdater) updatePPMShipment(appCtx appcontext.AppContext, ppm updatedPPMShipment.TertiaryDestinationAddress = updatedAddress } + // if the actual move date is being provided, we no longer need to calculate the estimate - it has already happened + if updatedPPMShipment.ActualMoveDate == nil { + estimatedIncentive, estimatedSITCost, err := f.estimator.EstimateIncentiveWithDefaultChecks(appCtx, *oldPPMShipment, updatedPPMShipment) + if err != nil { + return err + } + updatedPPMShipment.EstimatedIncentive = estimatedIncentive + updatedPPMShipment.SITEstimatedCost = estimatedSITCost + } + + // if the PPM shipment is past closeout then we should not calculate the max incentive, it is already set in stone + if oldPPMShipment.Status != models.PPMShipmentStatusWaitingOnCustomer && + oldPPMShipment.Status != models.PPMShipmentStatusCloseoutComplete && + oldPPMShipment.Status != models.PPMShipmentStatusComplete && + oldPPMShipment.Status != models.PPMShipmentStatusNeedsCloseout { + maxIncentive, err := f.estimator.MaxIncentive(appCtx, *oldPPMShipment, updatedPPMShipment) + if err != nil { + return err + } + updatedPPMShipment.MaxIncentive = maxIncentive + } + + if appCtx.Session() != nil { + if appCtx.Session().IsOfficeUser() { + edited := models.PPMAdvanceStatusEdited + if oldPPMShipment.HasRequestedAdvance != nil && updatedPPMShipment.HasRequestedAdvance != nil { + if !*oldPPMShipment.HasRequestedAdvance && *updatedPPMShipment.HasRequestedAdvance { + updatedPPMShipment.AdvanceStatus = &edited + } else if *oldPPMShipment.HasRequestedAdvance && !*updatedPPMShipment.HasRequestedAdvance { + updatedPPMShipment.AdvanceStatus = &edited + } + } + if oldPPMShipment.AdvanceAmountRequested != nil && updatedPPMShipment.AdvanceAmountRequested != nil { + if *oldPPMShipment.AdvanceAmountRequested != *updatedPPMShipment.AdvanceAmountRequested { + updatedPPMShipment.AdvanceStatus = &edited + } + } + } + if appCtx.Session().IsMilApp() { + if isPrimeCounseled && updatedPPMShipment.HasRequestedAdvance != nil { + received := models.PPMAdvanceStatusReceived + notReceived := models.PPMAdvanceStatusNotReceived + + if updatedPPMShipment.HasReceivedAdvance != nil && *updatedPPMShipment.HasRequestedAdvance { + if *updatedPPMShipment.HasReceivedAdvance { + updatedPPMShipment.AdvanceStatus = &received + } + if !*updatedPPMShipment.HasReceivedAdvance { + updatedPPMShipment.AdvanceStatus = ¬Received + } + } + } + } + } + + if updatedPPMShipment.ActualMoveDate != nil { + finalIncentive, err := f.estimator.FinalIncentiveWithDefaultChecks(appCtx, *oldPPMShipment, updatedPPMShipment) + if err != nil { + return err + } + updatedPPMShipment.FinalIncentive = finalIncentive + } + verrs, err := appCtx.DB().ValidateAndUpdate(updatedPPMShipment) if verrs != nil && verrs.HasAny() { return apperror.NewInvalidInputError(updatedPPMShipment.ID, err, verrs, "Invalid input found while updating the PPMShipments.") diff --git a/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.jsx b/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.jsx index 603305cd7c2..a9201b3ec3f 100644 --- a/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.jsx +++ b/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.jsx @@ -62,6 +62,9 @@ const getSectionMarkup = (sectionInfo, handleEditOnClick, isFetchingItems, updat const isCivilian = grade === 'CIVILIAN_EMPLOYEE'; const renderHaulType = (haulType) => { + if (haulType === '') { + return null; + } return haulType === HAUL_TYPES.LINEHAUL ? 'Linehaul' : 'Shorthaul'; }; // check if the itemName is one of the items recalulated after item edit(updatedItemName). @@ -268,16 +271,18 @@ const getSectionMarkup = (sectionInfo, handleEditOnClick, isFetchingItems, updat case sectionTypes.incentiveFactors: return (
-
- - - {isFetchingItems && isRecalulatedItem('haulPrice') ? ( - - ) : ( - `$${formatCents(sectionInfo.haulPrice)}` - )} - -
+ {sectionInfo.haulPrice > 0 && ( +
+ + + {isFetchingItems && isRecalulatedItem('haulPrice') ? ( + + ) : ( + `$${formatCents(sectionInfo.haulPrice)}` + )} + +
+ )}
@@ -311,6 +316,7 @@ const getSectionMarkup = (sectionInfo, handleEditOnClick, isFetchingItems, updat )}
+
@@ -321,6 +327,7 @@ const getSectionMarkup = (sectionInfo, handleEditOnClick, isFetchingItems, updat )}
+
@@ -331,6 +338,36 @@ const getSectionMarkup = (sectionInfo, handleEditOnClick, isFetchingItems, updat )}
+
+ + + {isFetchingItems && isRecalulatedItem('intlPackPrice') ? ( + + ) : ( + `$${formatCents(sectionInfo.intlPackPrice)}` + )} + +
+
+ + + {isFetchingItems && isRecalulatedItem('intlUnpackPrice') ? ( + + ) : ( + `$${formatCents(sectionInfo.intlUnpackPrice)}` + )} + +
+
+ + + {isFetchingItems && isRecalulatedItem('intlLinehaulPrice') ? ( + + ) : ( + `$${formatCents(sectionInfo.intlLinehaulPrice)}` + )} + +
diff --git a/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.test.jsx b/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.test.jsx index 7fb05d4da7d..4b173db4bdb 100644 --- a/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.test.jsx +++ b/src/components/Office/PPM/PPMHeaderSummary/HeaderSection.test.jsx @@ -219,16 +219,24 @@ const incentivesAdvanceReceivedZeroProps = { setIsSubmitting: jest.fn(), }; +const HAUL_TYPES = { + SHORTHAUL: 'Shorthaul', + LINEHAUL: 'Linehaul', +}; + const incentiveFactorsProps = { sectionInfo: { type: 'incentiveFactors', - haulType: 'Linehaul', + haulType: HAUL_TYPES.LINEHAUL, haulPrice: 6892668, - haulFSC: -143, + haulFSC: 143, packPrice: 20000, unpackPrice: 10000, dop: 15640, ddp: 34640, + intlPackPrice: 1234, + intlUnpackPrice: 12345, + intlLinehaulPrice: 123456, sitReimbursement: 30000, }, }; @@ -469,7 +477,7 @@ describe('PPMHeaderSummary component', () => { expect(screen.getByText('Linehaul Price')).toBeInTheDocument(); expect(screen.getByTestId('haulPrice')).toHaveTextContent('$68,926.68'); expect(screen.getByText('Linehaul Fuel Rate Adjustment')).toBeInTheDocument(); - expect(screen.getByTestId('haulFSC')).toHaveTextContent('-$1.43'); + expect(screen.getByTestId('haulFSC')).toHaveTextContent('$1.43'); expect(screen.getByText('Packing Charge')).toBeInTheDocument(); expect(screen.getByTestId('packPrice')).toHaveTextContent('$200.00'); expect(screen.getByText('Unpacking Charge')).toBeInTheDocument(); @@ -478,6 +486,12 @@ describe('PPMHeaderSummary component', () => { expect(screen.getByTestId('originPrice')).toHaveTextContent('$156.40'); expect(screen.getByText('Destination Price')).toBeInTheDocument(); expect(screen.getByTestId('destinationPrice')).toHaveTextContent('$346.40'); + expect(screen.getByText('International Packing Charge')).toBeInTheDocument(); + expect(screen.getByTestId('intlPackPrice')).toHaveTextContent('$12.34'); + expect(screen.getByText('International Unpacking Charge')).toBeInTheDocument(); + expect(screen.getByTestId('intlUnpackPrice')).toHaveTextContent('$123.45'); + expect(screen.getByText('International Shipping & Linehaul Charge')).toBeInTheDocument(); + expect(screen.getByTestId('intlLinehaulPrice')).toHaveTextContent('$1,234.56'); expect(screen.getByTestId('sitReimbursement')).toHaveTextContent('$300.00'); }); diff --git a/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.jsx b/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.jsx index 955389774cd..23d84460bf7 100644 --- a/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.jsx +++ b/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.jsx @@ -36,6 +36,9 @@ const GCCAndIncentiveInfo = ({ ppmShipmentInfo, updatedItemName, setUpdatedItemN dop: ppmCloseout.dop, ddp: ppmCloseout.ddp, sitReimbursement: ppmCloseout.SITReimbursement, + intlPackPrice: ppmCloseout.intlPackPrice, + intlUnpackPrice: ppmCloseout.intlUnpackPrice, + intlLinehaulPrice: ppmCloseout.intlLinehaulPrice, }; return ( diff --git a/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.test.jsx b/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.test.jsx index 89c94406ccc..6d938da22b2 100644 --- a/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.test.jsx +++ b/src/components/Office/PPM/PPMHeaderSummary/PPMHeaderSummary.test.jsx @@ -167,7 +167,7 @@ const defaultProps = { describe('PPMHeaderSummary component', () => { describe('displays form', () => { - it('renders blank form on load with defaults', async () => { + it('renders default values', async () => { usePPMShipmentDocsQueries.mockReturnValue(useEditShipmentQueriesReturnValue); useEditShipmentQueries.mockReturnValue(useEditShipmentQueriesReturnValue); renderWithProviders(, mockRoutingConfig); diff --git a/src/components/Office/PPM/SitCostBreakdown/SitCostBreakdown.jsx b/src/components/Office/PPM/SitCostBreakdown/SitCostBreakdown.jsx index f275eb0f3a6..0b6412ddfe4 100644 --- a/src/components/Office/PPM/SitCostBreakdown/SitCostBreakdown.jsx +++ b/src/components/Office/PPM/SitCostBreakdown/SitCostBreakdown.jsx @@ -24,6 +24,11 @@ export default function SitCostBreakdown({ actualWeight, ); + const isEitherAddressOconus = (ppm) => { + return ppm?.destinationAddress?.isOconus || ppm?.pickupAddress?.isOconus; + }; + const isInternationalShipment = isEitherAddressOconus(ppmShipmentInfo); + setEstimatedCost(estimatedCost?.sitCost || 0); return (
@@ -36,18 +41,25 @@ export default function SitCostBreakdown({ SIT Information:
-
- - {estimatedCost.paramsFirstDaySIT.serviceAreaOrigin - ? `Origin service area: ${estimatedCost?.paramsFirstDaySIT.serviceAreaOrigin}` - : `Destination service area: ${estimatedCost?.paramsFirstDaySIT.serviceAreaDestination}`} - -
+ {(estimatedCost.paramsFirstDaySIT.serviceAreaOrigin || + estimatedCost.paramsFirstDaySIT.serviceAreaDestination) && ( +
+ + {estimatedCost.paramsFirstDaySIT.serviceAreaOrigin + ? `Origin service area: ${estimatedCost.paramsFirstDaySIT.serviceAreaOrigin}` + : `Destination service area: ${estimatedCost.paramsFirstDaySIT.serviceAreaDestination}`} + +
+ )}
Actual move date: {formatDate(ppmShipmentInfo.actualMoveDate)}
- {estimatedCost.paramsFirstDaySIT.isPeak ? 'Domestic peak' : 'Domestic non-peak'} + + {isInternationalShipment + ? `${estimatedCost.paramsFirstDaySIT.isPeak ? 'International peak' : 'International non-peak'}` + : `${estimatedCost.paramsFirstDaySIT.isPeak ? 'Domestic peak' : 'Domestic non-peak'}`} +
diff --git a/swagger-def/definitions/PPMCloseout.yaml b/swagger-def/definitions/PPMCloseout.yaml index 20203d0e4e7..f7b420a0e7b 100644 --- a/swagger-def/definitions/PPMCloseout.yaml +++ b/swagger-def/definitions/PPMCloseout.yaml @@ -6,7 +6,6 @@ properties: format: uuid type: string readOnly: true - plannedMoveDate: description: > Date the customer expects to begin their move. @@ -27,32 +26,27 @@ properties: type: integer x-nullable: true x-omitempty: false - estimatedWeight: description: The estimated weight of the PPM shipment goods being moved. type: integer example: 4200 x-nullable: true x-omitempty: false - actualWeight: example: 2000 type: integer x-nullable: true x-omitempty: false - proGearWeightCustomer: description: The estimated weight of the pro-gear being moved belonging to the service member. type: integer x-nullable: true x-omitempty: false - proGearWeightSpouse: description: The estimated weight of the pro-gear being moved belonging to a spouse. type: integer x-nullable: true x-omitempty: false - grossIncentive: description: > The final calculated incentive for the PPM shipment. This does not include **SIT** as it is a reimbursement. @@ -61,7 +55,6 @@ properties: x-nullable: true x-omitempty: false readOnly: true - gcc: description: Government Constructive Cost (GCC) type: integer @@ -69,75 +62,82 @@ properties: format: cents x-nullable: true x-omitempty: false - aoa: description: Advance Operating Allowance (AOA). type: integer format: cents x-nullable: true x-omitempty: false - remainingIncentive: description: The remaining reimbursement amount that is still owed to the customer. type: integer format: cents x-nullable: true x-omitempty: false - haulType: description: The type of haul calculation used for this shipment (shorthaul or linehaul). type: string x-nullable: true x-omitempty: false - haulPrice: description: The price of the linehaul or shorthaul. type: integer format: cents x-nullable: true x-omitempty: false - haulFSC: description: The linehaul/shorthaul Fuel Surcharge (FSC). type: integer format: cents x-nullable: true x-omitempty: false - dop: description: The Domestic Origin Price (DOP). type: integer format: cents x-nullable: true x-omitempty: false - ddp: description: The Domestic Destination Price (DDP). type: integer format: cents x-nullable: true x-omitempty: false - packPrice: description: The full price of all packing/unpacking services. type: integer format: cents x-nullable: true x-omitempty: false - unpackPrice: description: The full price of all packing/unpacking services. type: integer format: cents x-nullable: true x-omitempty: false - + intlPackPrice: + description: The full price of international packing (IHPK) + type: integer + format: cents + x-nullable: true + x-omitempty: false + intlUnpackPrice: + description: The full price of international unpacking (IHUPK) + type: integer + format: cents + x-nullable: true + x-omitempty: false + intlLinehaulPrice: + description: The full price of international shipping and linehaul (ISLH) + type: integer + format: cents + x-nullable: true + x-omitempty: false SITReimbursement: description: The estimated amount that the government will pay the service member to put their goods into storage. This estimated storage cost is separate from the estimated incentive. type: integer format: cents x-nullable: true x-omitempty: false - required: - id diff --git a/swagger/ghc.yaml b/swagger/ghc.yaml index 54131042af0..3e36bd8ebaf 100644 --- a/swagger/ghc.yaml +++ b/swagger/ghc.yaml @@ -11042,6 +11042,24 @@ definitions: format: cents x-nullable: true x-omitempty: false + intlPackPrice: + description: The full price of international packing (IHPK) + type: integer + format: cents + x-nullable: true + x-omitempty: false + intlUnpackPrice: + description: The full price of international unpacking (IHUPK) + type: integer + format: cents + x-nullable: true + x-omitempty: false + intlLinehaulPrice: + description: The full price of international shipping and linehaul (ISLH) + type: integer + format: cents + x-nullable: true + x-omitempty: false SITReimbursement: description: >- The estimated amount that the government will pay the service member