diff --git a/modules/connectors/fedex/karrio/providers/fedex/pickup/create.py b/modules/connectors/fedex/karrio/providers/fedex/pickup/create.py index 67f716e924..993c47a497 100644 --- a/modules/connectors/fedex/karrio/providers/fedex/pickup/create.py +++ b/modules/connectors/fedex/karrio/providers/fedex/pickup/create.py @@ -8,6 +8,12 @@ import karrio.providers.fedex.error as error import karrio.providers.fedex.utils as provider_utils import karrio.providers.fedex.units as provider_units +from karrio.providers.fedex.pickup.utils import ( + validate_package_location, + validate_pickup_address_type, + resolve_notification_emails, + build_notification_email_details, +) def parse_pickup_response( @@ -45,7 +51,9 @@ def pickup_request( payload: models.PickupRequest, settings: provider_utils.Settings, ) -> lib.Serializable: + package_location = validate_package_location(payload.package_location) address = lib.to_address(payload.address) + notification_emails = resolve_notification_emails(address.email, payload.options) packages = lib.to_packages(payload.parcels) options = lib.units.Options( payload.options, @@ -67,6 +75,9 @@ def pickup_request( # fmt: on ), ) + pickup_address_type = validate_pickup_address_type( + options.fedex_pickup_address_type.state + ) # Map unified pickup_type to FedEx pickup type # one_time -> ON_CALL, daily/recurring -> REGULAR_STOP @@ -87,7 +98,7 @@ def pickup_request( value=settings.account_number, ), originDetail=fedex.OriginDetailType( - pickupAddressType=options.fedex_pickup_address_type.state, + pickupAddressType=pickup_address_type, pickupLocation=fedex.PickupLocationType( contact=fedex.ContactType( companyName=address.company_name, @@ -113,7 +124,7 @@ def pickup_request( readyDateTimestamp=f"{payload.pickup_date}T{ready_time}:00Z", customerCloseTime=f"{closing_time}:00", pickupDateType=options.fedex_pickup_date_type.state, - packageLocation=payload.package_location, + packageLocation=package_location, buildingPart=options.fedex_building_part.state, buildingPartDescription=options.fedex_building_part_description.state, earlyPickup=options.fedex_early_pickup.state, @@ -128,7 +139,7 @@ def pickup_request( packageCount=len(packages), carrierCode=options.fedex_carrier_code.state or "FDXE", accountAddressOfRecord=None, - remarks=None, + remarks=payload.instruction, countryRelationships=None, pickupType=fedex_pickup_type, trackingNumber=None, @@ -137,16 +148,11 @@ def pickup_request( oversizePackageCount=None, pickupNotificationDetail=lib.identity( fedex.PickupNotificationDetailType( - emailDetails=[ - fedex.EmailDetailType( - address=address.email, - locale="en_US", - ) - ], + emailDetails=build_notification_email_details(notification_emails), format="TEXT", userMessage=options.fedex_user_message.state, ) - if address.email + if any(notification_emails) else None ), ) diff --git a/modules/connectors/fedex/karrio/providers/fedex/pickup/update.py b/modules/connectors/fedex/karrio/providers/fedex/pickup/update.py index cfc61851a4..c08b8e7851 100644 --- a/modules/connectors/fedex/karrio/providers/fedex/pickup/update.py +++ b/modules/connectors/fedex/karrio/providers/fedex/pickup/update.py @@ -9,6 +9,12 @@ import karrio.providers.fedex.utils as provider_utils import karrio.providers.fedex.units as provider_units import karrio.providers.fedex.pickup.cancel as cancel +from karrio.providers.fedex.pickup.utils import ( + validate_package_location, + validate_pickup_address_type, + resolve_notification_emails, + build_notification_email_details, +) def parse_pickup_update_response( @@ -46,7 +52,9 @@ def pickup_update_request( payload: models.PickupUpdateRequest, settings: provider_utils.Settings, ) -> lib.Serializable: + package_location = validate_package_location(payload.package_location) address = lib.to_address(payload.address) + notification_emails = resolve_notification_emails(address.email, payload.options) packages = lib.to_packages(payload.parcels) options = lib.units.Options( payload.options, @@ -68,6 +76,9 @@ def pickup_update_request( # fmt: on ), ) + pickup_address_type = validate_pickup_address_type( + options.fedex_pickup_address_type.state + ) # Normalize times to HH:MM format to handle both HH:MM and HH:MM:SS inputs ready_time = lib.ftime(payload.ready_time, try_formats=["%H:%M:%S", "%H:%M"]) or payload.ready_time @@ -79,7 +90,7 @@ def pickup_update_request( value=settings.account_number, ), originDetail=fedex.OriginDetailType( - pickupAddressType=options.fedex_pickup_address_type.state, + pickupAddressType=pickup_address_type, pickupLocation=fedex.PickupLocationType( contact=fedex.ContactType( companyName=address.company_name, @@ -105,7 +116,7 @@ def pickup_update_request( readyDateTimestamp=f"{payload.pickup_date}T{ready_time}:00Z", customerCloseTime=f"{closing_time}:00", pickupDateType=options.fedex_pickup_date_type.state, - packageLocation=payload.package_location, + packageLocation=package_location, buildingPart=options.fedex_building_part.state, buildingPartDescription=options.fedex_building_part_description.state, earlyPickup=options.fedex_early_pickup.state, @@ -129,16 +140,11 @@ def pickup_update_request( oversizePackageCount=None, pickupNotificationDetail=lib.identity( fedex.PickupNotificationDetailType( - emailDetails=[ - fedex.EmailDetailType( - address=address.email, - locale="en_US", - ) - ], + emailDetails=build_notification_email_details(notification_emails), format="TEXT", userMessage=options.fedex_user_message.state, ) - if address.email + if any(notification_emails) else None ), ) diff --git a/modules/connectors/fedex/karrio/providers/fedex/pickup/utils.py b/modules/connectors/fedex/karrio/providers/fedex/pickup/utils.py new file mode 100644 index 0000000000..f7184e4dfc --- /dev/null +++ b/modules/connectors/fedex/karrio/providers/fedex/pickup/utils.py @@ -0,0 +1,105 @@ +import typing +import karrio.lib as lib +import karrio.schemas.fedex.pickup_request as fedex + + +FEDEX_PACKAGE_LOCATION_VALUES = {"FRONT", "NONE", "REAR", "SIDE"} +FEDEX_PICKUP_ADDRESS_TYPE_VALUES = {"ACCOUNT", "SHIPPER", "OTHER"} +FEDEX_MAX_NOTIFICATION_EMAILS = 5 + + +def _normalize_email_values( + value: typing.Any, +) -> typing.List[str]: + if value is None: + return [] + + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + + return [ + _.strip() + for _ in str(value).replace(";", ",").split(",") + if _.strip() + ] + + +def validate_package_location(value: typing.Optional[str]) -> typing.Optional[str]: + if value is None: + return None + + package_location = str(value).strip().upper() + if package_location not in FEDEX_PACKAGE_LOCATION_VALUES: + raise lib.exceptions.FieldError( + { + "package_location": ( + f"Invalid FedEx package location '{value}'. " + f"Expected one of: {sorted(FEDEX_PACKAGE_LOCATION_VALUES)}" + ) + } + ) + + return package_location + + +def validate_pickup_address_type(value: typing.Optional[str]) -> str: + if value is None: + return "OTHER" + + pickup_address_type = str(value).strip().upper() + if pickup_address_type not in FEDEX_PICKUP_ADDRESS_TYPE_VALUES: + raise lib.exceptions.FieldError( + { + "fedex_pickup_address_type": ( + f"Invalid FedEx pickup address type '{value}'. " + f"Expected one of: {sorted(FEDEX_PICKUP_ADDRESS_TYPE_VALUES)}" + ) + } + ) + + return pickup_address_type + + +def resolve_notification_emails( + primary_email: typing.Optional[str], options: typing.Optional[dict] +) -> typing.List[str]: + options = options or {} + emails: typing.List[str] = [] + + if primary_email: + emails.append(str(primary_email).strip()) + + emails.extend(_normalize_email_values(options.get("email_notification_to"))) + emails.extend(_normalize_email_values(options.get("fedex_notification_emails"))) + + unique_emails: typing.List[str] = [] + seen = set() + for email in emails: + key = email.lower() + if key not in seen: + unique_emails.append(email) + seen.add(key) + + if len(unique_emails) > FEDEX_MAX_NOTIFICATION_EMAILS: + raise lib.exceptions.FieldError( + { + "fedex_notification_emails": ( + f"FedEx pickup notification supports up to {FEDEX_MAX_NOTIFICATION_EMAILS} email addresses. " + f"Received: {len(unique_emails)}" + ) + } + ) + + return unique_emails + + +def build_notification_email_details( + emails: typing.List[str], +) -> typing.List[fedex.EmailDetailType]: + return [ + fedex.EmailDetailType( + address=email, + locale="en_US", + ) + for email in emails + ] \ No newline at end of file diff --git a/modules/connectors/fedex/tests/fedex/test_pickup.py b/modules/connectors/fedex/tests/fedex/test_pickup.py index 03ba676ab7..3bfec0c534 100644 --- a/modules/connectors/fedex/tests/fedex/test_pickup.py +++ b/modules/connectors/fedex/tests/fedex/test_pickup.py @@ -26,6 +26,115 @@ def test_create_pickup_request_with_seconds_in_time(self): # Should produce the same output as PickupRequest (times normalized) self.assertEqual(request.serialize(), PickupRequest) + def test_create_pickup_request_maps_instruction_to_remarks(self): + payload_with_instruction = { + **PickupPayload, + "instruction": "Please ring bell at loading dock.", + } + request = gateway.mapper.create_pickup_request( + models.PickupRequest(**payload_with_instruction) + ) + + self.assertEqual( + request.serialize().get("remarks"), + "Please ring bell at loading dock.", + ) + + def test_create_pickup_request_supports_extra_notification_emails(self): + payload_with_emails = { + **PickupPayload, + "options": { + **(PickupPayload.get("options") or {}), + "fedex_notification_emails": [ + "ops@xyz.com", + "support@xyz.com", + ], + }, + } + request = gateway.mapper.create_pickup_request( + models.PickupRequest(**payload_with_emails) + ) + + self.assertEqual( + request.serialize() + .get("pickupNotificationDetail", {}) + .get("emailDetails"), + [ + {"address": "jane.smith@xyz.com", "locale": "en_US"}, + {"address": "ops@xyz.com", "locale": "en_US"}, + {"address": "support@xyz.com", "locale": "en_US"}, + ], + ) + + def test_create_pickup_request_supports_email_notification_to_array(self): + payload_with_emails = { + **PickupPayload, + "options": { + **(PickupPayload.get("options") or {}), + "email_notification_to": [ + "cnolan@saundersbook.ca", + "ops@xyz.com", + ], + }, + } + request = gateway.mapper.create_pickup_request( + models.PickupRequest(**payload_with_emails) + ) + + self.assertEqual( + request.serialize() + .get("pickupNotificationDetail", {}) + .get("emailDetails"), + [ + {"address": "jane.smith@xyz.com", "locale": "en_US"}, + {"address": "cnolan@saundersbook.ca", "locale": "en_US"}, + {"address": "ops@xyz.com", "locale": "en_US"}, + ], + ) + + def test_create_pickup_request_supports_comma_separated_email_notification_to(self): + payload_with_emails = { + **PickupPayload, + "options": { + **(PickupPayload.get("options") or {}), + "email_notification_to": "cnolan@saundersbook.ca,ops@xyz.com", + }, + } + request = gateway.mapper.create_pickup_request( + models.PickupRequest(**payload_with_emails) + ) + + self.assertEqual( + request.serialize() + .get("pickupNotificationDetail", {}) + .get("emailDetails"), + [ + {"address": "jane.smith@xyz.com", "locale": "en_US"}, + {"address": "cnolan@saundersbook.ca", "locale": "en_US"}, + {"address": "ops@xyz.com", "locale": "en_US"}, + ], + ) + + def test_create_pickup_request_raises_for_too_many_notification_emails(self): + payload_with_too_many_emails = { + **PickupPayload, + "options": { + **(PickupPayload.get("options") or {}), + "fedex_notification_emails": [ + "one@xyz.com", + "two@xyz.com", + "three@xyz.com", + "four@xyz.com", + "five@xyz.com", + ], + }, + } + + with self.assertRaises(lib.exceptions.FieldError): + gateway.mapper.create_pickup_request( + models.PickupRequest(**payload_with_too_many_emails) + ) + def test_create_update_pickup_request(self): request = gateway.mapper.create_pickup_update_request(self.PickupUpdateRequest) @@ -39,6 +148,27 @@ def test_create_update_pickup_request_with_seconds_in_time(self): # Should produce the same output as PickupUpdateRequest (times normalized) self.assertEqual(request.serialize(), PickupUpdateRequest) + def test_create_pickup_request_invalid_package_location(self): + invalid_payload = { + **PickupPayload, + "package_location": "behind the front desk", + } + + with self.assertRaises(lib.exceptions.FieldError): + gateway.mapper.create_pickup_request(models.PickupRequest(**invalid_payload)) + + def test_create_pickup_request_invalid_pickup_address_type(self): + invalid_payload = { + **PickupPayload, + "options": { + **(PickupPayload.get("options") or {}), + "fedex_pickup_address_type": "BUSINESS", + }, + } + + with self.assertRaises(lib.exceptions.FieldError): + gateway.mapper.create_pickup_request(models.PickupRequest(**invalid_payload)) + def test_create_cancel_pickup_request(self): request = gateway.mapper.create_cancel_pickup_request(self.PickupCancelRequest) @@ -103,7 +233,7 @@ def test_parse_cancel_pickup_response(self): "pickup_date": "2013-10-19", "ready_time": "11:00", "closing_time": "09:20", - "package_location": "behind the front desk", + "package_location": "FRONT", "address": { "company_name": "XYZ Inc.", "address_line1": "456 Oak Avenue", @@ -120,7 +250,6 @@ def test_parse_cancel_pickup_response(self): "parcels": [{"weight": 20, "weight_unit": "LB"}], "options": { "fedex_carrier_code": "FDXE", - "fedex_pickup_address_type": "BUSINESS", }, } @@ -128,7 +257,7 @@ def test_parse_cancel_pickup_response(self): "pickup_date": "2013-10-19", "ready_time": "11:00:00", # HH:MM:SS format (some browsers send this) "closing_time": "09:20:00", # HH:MM:SS format - "package_location": "behind the front desk", + "package_location": "FRONT", "address": { "company_name": "XYZ Inc.", "address_line1": "456 Oak Avenue", @@ -145,7 +274,6 @@ def test_parse_cancel_pickup_response(self): "parcels": [{"weight": 20, "weight_unit": "LB"}], "options": { "fedex_carrier_code": "FDXE", - "fedex_pickup_address_type": "BUSINESS", }, } @@ -154,7 +282,7 @@ def test_parse_cancel_pickup_response(self): "pickup_date": "2013-10-19", "ready_time": "11:00", "closing_time": "09:20", - "package_location": "behind the front desk", + "package_location": "FRONT", "address": { "company_name": "XYZ Inc.", "address_line1": "456 Oak Avenue", @@ -171,7 +299,6 @@ def test_parse_cancel_pickup_response(self): "parcels": [{"weight": 20, "weight_unit": "LB"}], "options": { "fedex_carrier_code": "FDXE", - "fedex_pickup_address_type": "BUSINESS", }, } @@ -180,7 +307,7 @@ def test_parse_cancel_pickup_response(self): "pickup_date": "2013-10-19", "ready_time": "11:00:00", # HH:MM:SS format "closing_time": "09:20:00", # HH:MM:SS format - "package_location": "behind the front desk", + "package_location": "FRONT", "address": { "company_name": "XYZ Inc.", "address_line1": "456 Oak Avenue", @@ -197,7 +324,6 @@ def test_parse_cancel_pickup_response(self): "parcels": [{"weight": 20, "weight_unit": "LB"}], "options": { "fedex_carrier_code": "FDXE", - "fedex_pickup_address_type": "BUSINESS", }, } @@ -254,8 +380,8 @@ def test_parse_cancel_pickup_response(self): "carrierCode": "FDXE", "originDetail": { "customerCloseTime": "09:20:00", - "packageLocation": "behind the front desk", - "pickupAddressType": "BUSINESS", + "packageLocation": "FRONT", + "pickupAddressType": "OTHER", "pickupLocation": { "accountNumber": {"value": "2349857"}, "address": { @@ -288,8 +414,8 @@ def test_parse_cancel_pickup_response(self): "carrierCode": "FDXE", "originDetail": { "customerCloseTime": "09:20:00", - "packageLocation": "behind the front desk", - "pickupAddressType": "BUSINESS", + "packageLocation": "FRONT", + "pickupAddressType": "OTHER", "pickupLocation": { "accountNumber": {"value": "2349857"}, "address": {