From 0343ba4704cedada7ae9a269510ca8625082f2f3 Mon Sep 17 00:00:00 2001 From: Daniel K Date: Thu, 1 Aug 2024 12:56:45 -0700 Subject: [PATCH 1/3] feat: introduce SAPIENT carrier HUB API --- .../karrio/mappers/colissimo/settings.py | 7 + .../dpdhl/karrio/mappers/dpdhl/settings.py | 7 + .../geodis/karrio/mappers/geodis/settings.py | 7 + modules/connectors/sapient/README.md | 31 + modules/connectors/sapient/generate | 17 + .../karrio/mappers/sapient/__init__.py | 22 + .../sapient/karrio/mappers/sapient/mapper.py | 69 ++ .../sapient/karrio/mappers/sapient/proxy.py | 76 +++ .../karrio/mappers/sapient/settings.py | 35 + .../karrio/providers/sapient/__init__.py | 18 + .../sapient/karrio/providers/sapient/error.py | 28 + .../providers/sapient/pickup/__init__.py | 4 + .../karrio/providers/sapient/pickup/cancel.py | 55 ++ .../karrio/providers/sapient/pickup/create.py | 82 +++ .../karrio/providers/sapient/pickup/update.py | 78 +++ .../providers/sapient/shipment/__init__.py | 9 + .../providers/sapient/shipment/cancel.py | 56 ++ .../providers/sapient/shipment/create.py | 229 +++++++ .../sapient/karrio/providers/sapient/units.py | 627 ++++++++++++++++++ .../sapient/karrio/providers/sapient/utils.py | 99 +++ .../karrio/schemas/sapient/__init__.py | 0 .../karrio/schemas/sapient/error_response.py | 16 + .../karrio/schemas/sapient/pickup_request.py | 9 + .../karrio/schemas/sapient/pickup_response.py | 8 + .../schemas/sapient/shipment_requests.py | 125 ++++ .../schemas/sapient/shipment_response.py | 24 + .../sapient/schemas/error_response.json | 15 + .../sapient/schemas/pickup_request.json | 5 + .../sapient/schemas/pickup_response.json | 4 + .../sapient/schemas/shipment_requests.json | 241 +++++++ .../sapient/schemas/shipment_response.json | 15 + modules/connectors/sapient/setup.py | 27 + modules/connectors/sapient/tests/__init__.py | 2 + .../sapient/tests/sapient/__init__.py | 0 .../sapient/tests/sapient/fixture.py | 25 + .../sapient/tests/sapient/test_pickup.py | 181 +++++ .../sapient/tests/sapient/test_shipment.py | 275 ++++++++ .../server/providers/extension/models/dpd.py | 1 + .../providers/extension/models/generic.py | 2 +- .../providers/extension/models/sapient.py | 22 + modules/sdk/karrio/core/models.py | 4 +- 41 files changed, 2554 insertions(+), 3 deletions(-) create mode 100644 modules/connectors/sapient/README.md create mode 100755 modules/connectors/sapient/generate create mode 100644 modules/connectors/sapient/karrio/mappers/sapient/__init__.py create mode 100644 modules/connectors/sapient/karrio/mappers/sapient/mapper.py create mode 100644 modules/connectors/sapient/karrio/mappers/sapient/proxy.py create mode 100644 modules/connectors/sapient/karrio/mappers/sapient/settings.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/__init__.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/error.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/pickup/__init__.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/pickup/cancel.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/pickup/create.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/pickup/update.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/shipment/__init__.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/shipment/cancel.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/shipment/create.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/units.py create mode 100644 modules/connectors/sapient/karrio/providers/sapient/utils.py create mode 100644 modules/connectors/sapient/karrio/schemas/sapient/__init__.py create mode 100644 modules/connectors/sapient/karrio/schemas/sapient/error_response.py create mode 100644 modules/connectors/sapient/karrio/schemas/sapient/pickup_request.py create mode 100644 modules/connectors/sapient/karrio/schemas/sapient/pickup_response.py create mode 100644 modules/connectors/sapient/karrio/schemas/sapient/shipment_requests.py create mode 100644 modules/connectors/sapient/karrio/schemas/sapient/shipment_response.py create mode 100644 modules/connectors/sapient/schemas/error_response.json create mode 100644 modules/connectors/sapient/schemas/pickup_request.json create mode 100644 modules/connectors/sapient/schemas/pickup_response.json create mode 100644 modules/connectors/sapient/schemas/shipment_requests.json create mode 100644 modules/connectors/sapient/schemas/shipment_response.json create mode 100644 modules/connectors/sapient/setup.py create mode 100644 modules/connectors/sapient/tests/__init__.py create mode 100644 modules/connectors/sapient/tests/sapient/__init__.py create mode 100644 modules/connectors/sapient/tests/sapient/fixture.py create mode 100644 modules/connectors/sapient/tests/sapient/test_pickup.py create mode 100644 modules/connectors/sapient/tests/sapient/test_shipment.py create mode 100644 modules/core/karrio/server/providers/extension/models/sapient.py diff --git a/modules/connectors/colissimo/karrio/mappers/colissimo/settings.py b/modules/connectors/colissimo/karrio/mappers/colissimo/settings.py index 57d2d059b3..913ad46425 100644 --- a/modules/connectors/colissimo/karrio/mappers/colissimo/settings.py +++ b/modules/connectors/colissimo/karrio/mappers/colissimo/settings.py @@ -26,3 +26,10 @@ class Settings(provider_utils.Settings): config: dict = {} services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore + + @property + def shipping_services(self) -> typing.List[models.ServiceLevel]: + if any(self.services or []): + return self.services + + return provider_units.DEFAULT_SERVICES diff --git a/modules/connectors/dpdhl/karrio/mappers/dpdhl/settings.py b/modules/connectors/dpdhl/karrio/mappers/dpdhl/settings.py index 296e9c47d0..eb067b8786 100644 --- a/modules/connectors/dpdhl/karrio/mappers/dpdhl/settings.py +++ b/modules/connectors/dpdhl/karrio/mappers/dpdhl/settings.py @@ -31,3 +31,10 @@ class Settings(provider_utils.Settings, rating_proxy.RatingMixinSettings): config: dict = {} services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore + + @property + def shipping_services(self) -> typing.List[models.ServiceLevel]: + if any(self.services or []): + return self.services + + return provider_units.DEFAULT_SERVICES diff --git a/modules/connectors/geodis/karrio/mappers/geodis/settings.py b/modules/connectors/geodis/karrio/mappers/geodis/settings.py index dfa1314ee4..94d92ad6d1 100644 --- a/modules/connectors/geodis/karrio/mappers/geodis/settings.py +++ b/modules/connectors/geodis/karrio/mappers/geodis/settings.py @@ -27,3 +27,10 @@ class Settings(provider_utils.Settings): config: dict = {} services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore + + @property + def shipping_services(self) -> typing.List[models.ServiceLevel]: + if any(self.services or []): + return self.services + + return provider_units.DEFAULT_SERVICES diff --git a/modules/connectors/sapient/README.md b/modules/connectors/sapient/README.md new file mode 100644 index 0000000000..aaffc41bd0 --- /dev/null +++ b/modules/connectors/sapient/README.md @@ -0,0 +1,31 @@ + +# karrio.sapient + +This package is a SAPIENT extension of the [karrio](https://pypi.org/project/karrio) multi carrier shipping SDK. + +## Requirements + +`Python 3.7+` + +## Installation + +```bash +pip install karrio.sapient +``` + +## Usage + +```python +import karrio +from karrio.mappers.sapient.settings import Settings + + +# Initialize a carrier gateway +sapient = karrio.gateway["sapient"].create( + Settings( + ... + ) +) +``` + +Check the [Karrio Mutli-carrier SDK docs](https://docs.karrio.io) for Shipping API requests diff --git a/modules/connectors/sapient/generate b/modules/connectors/sapient/generate new file mode 100755 index 0000000000..7af2a4b690 --- /dev/null +++ b/modules/connectors/sapient/generate @@ -0,0 +1,17 @@ +SCHEMAS=./schemas +LIB_MODULES=./karrio/schemas/sapient +find "${LIB_MODULES}" -name "*.py" -exec rm -r {} \; +touch "${LIB_MODULES}/__init__.py" + +quicktype() { + echo "Generating $1..." + docker run -it --rm --name quicktype -v $PWD:/app -e SCHEMAS=/app/schemas -e LIB_MODULES=/app/karrio/schemas/sapient \ + karrio/tools /quicktype/script/quicktype --no-uuids --no-date-times --no-enums --src-lang json --lang jstruct \ + --no-nice-property-names --all-properties-optional --type-as-suffix $@ +} + +quicktype --src="${SCHEMAS}/error_response.json" --out="${LIB_MODULES}/error_response.py" +quicktype --src="${SCHEMAS}/pickup_request.json" --out="${LIB_MODULES}/pickup_request.py" +quicktype --src="${SCHEMAS}/pickup_response.json" --out="${LIB_MODULES}/pickup_response.py" +quicktype --src="${SCHEMAS}/shipment_requests.json" --out="${LIB_MODULES}/shipment_requests.py" +quicktype --src="${SCHEMAS}/shipment_response.json" --out="${LIB_MODULES}/shipment_response.py" diff --git a/modules/connectors/sapient/karrio/mappers/sapient/__init__.py b/modules/connectors/sapient/karrio/mappers/sapient/__init__.py new file mode 100644 index 0000000000..74d1c3494b --- /dev/null +++ b/modules/connectors/sapient/karrio/mappers/sapient/__init__.py @@ -0,0 +1,22 @@ +from karrio.core.metadata import Metadata + +from karrio.mappers.sapient.mapper import Mapper +from karrio.mappers.sapient.proxy import Proxy +from karrio.mappers.sapient.settings import Settings +import karrio.providers.sapient.units as units +import karrio.providers.sapient.utils as utils + + +METADATA = Metadata( + id="sapient", + label="SAPIENT", + # Integrations + Mapper=Mapper, + Proxy=Proxy, + Settings=Settings, + # Data Units + is_hub=False, + # options=units.ShippingOption, + # services=units.ShippingService, + # connection_configs=utils.ConnectionConfig, +) diff --git a/modules/connectors/sapient/karrio/mappers/sapient/mapper.py b/modules/connectors/sapient/karrio/mappers/sapient/mapper.py new file mode 100644 index 0000000000..64f6c55287 --- /dev/null +++ b/modules/connectors/sapient/karrio/mappers/sapient/mapper.py @@ -0,0 +1,69 @@ +"""Karrio SAPIENT client mapper.""" + +import typing +import karrio.lib as lib +import karrio.api.mapper as mapper +import karrio.core.models as models +import karrio.providers.sapient as provider +import karrio.mappers.sapient.settings as provider_settings +import karrio.universal.providers.rating as universal_provider + + +class Mapper(mapper.Mapper): + settings: provider_settings.Settings + + def create_rate_request(self, payload: models.RateRequest) -> lib.Serializable: + return universal_provider.rate_request(payload, self.settings) + + def create_shipment_request( + self, payload: models.ShipmentRequest + ) -> lib.Serializable: + return provider.shipment_request(payload, self.settings) + + def create_pickup_request(self, payload: models.PickupRequest) -> lib.Serializable: + return provider.pickup_request(payload, self.settings) + + def create_pickup_update_request( + self, payload: models.PickupUpdateRequest + ) -> lib.Serializable: + return provider.pickup_update_request(payload, self.settings) + + def create_cancel_pickup_request( + self, payload: models.PickupCancelRequest + ) -> lib.Serializable: + return provider.pickup_cancel_request(payload, self.settings) + + def create_cancel_shipment_request( + self, payload: models.ShipmentCancelRequest + ) -> lib.Serializable[str]: + return provider.shipment_cancel_request(payload, self.settings) + + def parse_cancel_pickup_response( + self, response: lib.Deserializable[str] + ) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]: + return provider.parse_pickup_cancel_response(response, self.settings) + + def parse_cancel_shipment_response( + self, response: lib.Deserializable[str] + ) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]: + return provider.parse_shipment_cancel_response(response, self.settings) + + def parse_pickup_response( + self, response: lib.Deserializable[str] + ) -> typing.Tuple[models.PickupDetails, typing.List[models.Message]]: + return provider.parse_pickup_response(response, self.settings) + + def parse_pickup_update_response( + self, response: lib.Deserializable[str] + ) -> typing.Tuple[models.PickupDetails, typing.List[models.Message]]: + return provider.parse_pickup_update_response(response, self.settings) + + def parse_rate_response( + self, response: lib.Deserializable[str] + ) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]: + return provider.parse_rate_response(response, self.settings) + + def parse_shipment_response( + self, response: lib.Deserializable[str] + ) -> typing.Tuple[models.ShipmentDetails, typing.List[models.Message]]: + return provider.parse_shipment_response(response, self.settings) diff --git a/modules/connectors/sapient/karrio/mappers/sapient/proxy.py b/modules/connectors/sapient/karrio/mappers/sapient/proxy.py new file mode 100644 index 0000000000..c258e26b7c --- /dev/null +++ b/modules/connectors/sapient/karrio/mappers/sapient/proxy.py @@ -0,0 +1,76 @@ +"""Karrio SAPIENT client proxy.""" + +import karrio.lib as lib +import karrio.api.proxy as proxy +import karrio.mappers.sapient.settings as provider_settings +import karrio.universal.mappers.rating_proxy as rating_proxy + + +class Proxy(rating_proxy.RatingMixinProxy, proxy.Proxy): + settings: provider_settings.Settings + + def get_rates(self, request: lib.Serializable) -> lib.Deserializable[str]: + return super().get_rates(request) + + def create_shipment(self, request: lib.Serializable) -> lib.Deserializable[str]: + response = lib.request( + url=f"{self.settings.server_url}/v4/shipments/{request.ctx['carrier']}", + data=lib.to_json(request.serialize()), + trace=self.trace_as("json"), + method="POST", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.settings.access_token}", + }, + ) + + return lib.Deserializable(response, lib.to_dict, request.ctx) + + def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable[str]: + response = lib.request( + url=f"{self.settings.server_url}/v4/shipments/status", + data=lib.to_json(request.serialize()), + trace=self.trace_as("json"), + method="PUT", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.settings.access_token}", + }, + ) + + return lib.Deserializable(response, lib.to_dict) + + def schedule_pickup(self, request: lib.Serializable) -> lib.Deserializable[str]: + response = lib.request( + url=f"{self.settings.server_url}/v4/collections/{request.ctx['carrier']}/{request.ctx['shipmentId']}", + data=lib.to_json(request.serialize()), + trace=self.trace_as("json"), + method="POST", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.settings.access_token}", + }, + ) + + return lib.Deserializable(response, lib.to_dict, request.ctx) + + def modify_pickup(self, request: lib.Serializable) -> lib.Deserializable[str]: + response = self.cancel_pickup(lib.Serializable(request.ctx)) + + if response.deserialize()["ok"]: + response = self.schedule_pickup(request) + + return lib.Deserializable(response, lib.to_dict) + + def cancel_pickup(self, request: lib.Serializable) -> lib.Deserializable[str]: + response = lib.request( + url=f"{self.settings.server_url}/v4/collections/{request.serialize()['carrier']}/{request.serialize()['shipmentId']}/cancel", + trace=self.trace_as("json"), + method="PUT", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.settings.access_token}", + }, + ) + + return lib.Deserializable(response, lib.to_dict) diff --git a/modules/connectors/sapient/karrio/mappers/sapient/settings.py b/modules/connectors/sapient/karrio/mappers/sapient/settings.py new file mode 100644 index 0000000000..cb2ab32cd1 --- /dev/null +++ b/modules/connectors/sapient/karrio/mappers/sapient/settings.py @@ -0,0 +1,35 @@ +"""Karrio SAPIENT client settings.""" + +import attr +import typing +import jstruct +import karrio.core.models as models +import karrio.providers.sapient.utils as provider_utils +import karrio.providers.sapient.units as provider_units + + +@attr.s(auto_attribs=True) +class Settings(provider_utils.Settings): + """SAPIENT connection settings.""" + + # Add carrier specific API connection properties here + client_id: str + client_secret: str + shipping_account_id: str + carrier_code: str = "RM" + + # generic properties + id: str = None + test_mode: bool = False + carrier_id: str = "sapient" + services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore + account_country_code: str = "GB" + metadata: dict = {} + config: dict = {} + + @property + def shipping_services(self) -> typing.List[models.ServiceLevel]: + if any(self.services or []): + return self.services + + return provider_units.DEFAULT_SERVICES diff --git a/modules/connectors/sapient/karrio/providers/sapient/__init__.py b/modules/connectors/sapient/karrio/providers/sapient/__init__.py new file mode 100644 index 0000000000..db4f4e4556 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/__init__.py @@ -0,0 +1,18 @@ +"""Karrio SAPIENT provider imports.""" + +from karrio.providers.sapient.utils import Settings + +from karrio.providers.sapient.shipment import ( + parse_shipment_cancel_response, + parse_shipment_response, + shipment_cancel_request, + shipment_request, +) +from karrio.providers.sapient.pickup import ( + parse_pickup_cancel_response, + parse_pickup_update_response, + parse_pickup_response, + pickup_update_request, + pickup_cancel_request, + pickup_request, +) diff --git a/modules/connectors/sapient/karrio/providers/sapient/error.py b/modules/connectors/sapient/karrio/providers/sapient/error.py new file mode 100644 index 0000000000..b09b089b50 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/error.py @@ -0,0 +1,28 @@ +"""Karrio SAPIENT error parser.""" + +import typing +import karrio.lib as lib +import karrio.core.models as models +import karrio.providers.sapient.utils as provider_utils + + +def parse_error_response( + response: typing.Union[dict, typing.List[dict]], + settings: provider_utils.Settings, + **kwargs, +) -> typing.List[models.Message]: + responses = response if isinstance(response, list) else [response] + errors: typing.List[dict] = sum( + [_["Errors"] for _ in responses if "Errors" in _], [] + ) + + return [ + models.Message( + carrier_id=settings.carrier_id, + carrier_name=settings.carrier_name, + code=error.get("ErrorCode", "UNKNOWN"), + message=error.get("Message", "Unknown error"), + details={**kwargs, "Cause": error.get("Cause")}, + ) + for error in errors + ] diff --git a/modules/connectors/sapient/karrio/providers/sapient/pickup/__init__.py b/modules/connectors/sapient/karrio/providers/sapient/pickup/__init__.py new file mode 100644 index 0000000000..8c6075a0d6 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/pickup/__init__.py @@ -0,0 +1,4 @@ + +from karrio.providers.sapient.pickup.create import parse_pickup_response, pickup_request +from karrio.providers.sapient.pickup.update import parse_pickup_update_response, pickup_update_request +from karrio.providers.sapient.pickup.cancel import parse_pickup_cancel_response, pickup_cancel_request diff --git a/modules/connectors/sapient/karrio/providers/sapient/pickup/cancel.py b/modules/connectors/sapient/karrio/providers/sapient/pickup/cancel.py new file mode 100644 index 0000000000..74029c3cca --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/pickup/cancel.py @@ -0,0 +1,55 @@ +import typing +import karrio.lib as lib +import karrio.core.units as units +import karrio.core.models as models +import karrio.providers.sapient.error as error +import karrio.providers.sapient.utils as provider_utils +import karrio.providers.sapient.units as provider_units + + +def parse_pickup_cancel_response( + _response: lib.Deserializable[dict], + settings: provider_utils.Settings, +) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]: + response = _response.deserialize() + messages = error.parse_error_response(response, settings) + success = True # compute address validation success state + + confirmation = ( + models.ConfirmationDetails( + carrier_id=settings.carrier_id, + carrier_name=settings.carrier_name, + operation="Cancel Pickup", + success=success, + ) + if success + else None + ) + + return confirmation, messages + + +def pickup_cancel_request( + payload: models.PickupCancelRequest, + settings: provider_utils.Settings, +) -> lib.Serializable: + options = lib.units.Options( + payload.options, + option_type=lib.units.create_enum( + "PickupOptions", + # fmt: off + { + "sapient_carrier": lib.OptionEnum("sapient_carrier"), + "sapient_shipment_id": lib.OptionEnum("sapient_shipment_id"), + }, + # fmt: on + ), + ) + + # map data to convert karrio model to sapient specific type + request = dict( + shipmentId=options.sapient_shipment_id.state, + carrier=options.sapient_carrier.state or settings.carrier_code, + ) + + return lib.Serializable(request, lib.to_dict) diff --git a/modules/connectors/sapient/karrio/providers/sapient/pickup/create.py b/modules/connectors/sapient/karrio/providers/sapient/pickup/create.py new file mode 100644 index 0000000000..fa462dd0c1 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/pickup/create.py @@ -0,0 +1,82 @@ +import karrio.schemas.sapient.pickup_request as sapient +import karrio.schemas.sapient.pickup_response as pickup + +import typing +import karrio.lib as lib +import karrio.core.units as units +import karrio.core.models as models +import karrio.providers.sapient.error as error +import karrio.providers.sapient.utils as provider_utils +import karrio.providers.sapient.units as provider_units + + +def parse_pickup_response( + _response: lib.Deserializable[dict], + settings: provider_utils.Settings, +) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]: + response = _response.deserialize() + + messages = error.parse_error_response(response, settings) + pickup = lib.identity( + _extract_details(response, settings, _response.ctx) + if "CollectionOrderId" in response + else None + ) + + return pickup, messages + + +def _extract_details( + data: dict, + settings: provider_utils.Settings, + ctx: dict = None, +) -> models.PickupDetails: + details = lib.to_object(pickup.PickupResponseType, data) + + return models.PickupDetails( + carrier_id=settings.carrier_id, + carrier_name=settings.carrier_name, + confirmation_number=details.CollectionOrderId, + pickup_date=lib.fdate(details.CollectionDate), + meta=dict( + sapient_shipment_id=ctx.get("shipmentId"), + sapient_carrier=ctx.get("carrier"), + ), + ) + + +def pickup_request( + payload: models.PickupRequest, + settings: provider_utils.Settings, +) -> lib.Serializable: + options = lib.units.Options( + payload.options, + option_type=lib.units.create_enum( + "PickupOptions", + # fmt: off + { + "sapient_carrier": lib.OptionEnum("sapient_carrier"), + "sapient_shipment_id": lib.OptionEnum("sapient_shipment_id"), + "sapient_bring_my_label": lib.OptionEnum("BringMyLabel"), + "sapient_slot_reservation_id": lib.OptionEnum("SlotReservationId"), + }, + # fmt: on + ), + ) + + # map data to convert karrio model to sapient specific type + request = sapient.PickupRequestType( + SlotDate=payload.pickup_date, + SlotReservationId=options.slot_reservation_id.state, + BringMyLabel=lib.identity( + options.sapient_bring_my_label.state + if options.sapient_bring_my_label.state is not None + else False + ), + ) + + return lib.Serializable( + request, + lib.to_dict, + dict(shipmentId=payload.options.shipment_id.state, carrier=carrier), + ) diff --git a/modules/connectors/sapient/karrio/providers/sapient/pickup/update.py b/modules/connectors/sapient/karrio/providers/sapient/pickup/update.py new file mode 100644 index 0000000000..40c2be4496 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/pickup/update.py @@ -0,0 +1,78 @@ +import karrio.schemas.sapient.pickup_request as sapient +import karrio.schemas.sapient.pickup_response as pickup + +import typing +import karrio.lib as lib +import karrio.core.units as units +import karrio.core.models as models +import karrio.providers.sapient.error as error +import karrio.providers.sapient.utils as provider_utils +import karrio.providers.sapient.units as provider_units + + +def parse_pickup_update_response( + _response: lib.Deserializable[dict], + settings: provider_utils.Settings, +) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]: + response = _response.deserialize() + + messages = error.parse_error_response(response, settings, _response.ctx) + pickup = lib.identity( + _extract_details(response, settings) + if "confirmation_number" in response + else None + ) + + return pickup, messages + + +def _extract_details( + data: dict, + settings: provider_utils.Settings, + ctx: dict = None, +) -> models.PickupDetails: + details = lib.to_object(pickup.PickupResponseType, data) + + return models.PickupDetails( + carrier_id=settings.carrier_id, + carrier_name=settings.carrier_name, + confirmation_number=details.CollectionOrderId, + pickup_date=lib.fdate(details.CollectionDate), + meta=dict(shipment_id=ctx.get("shipmentId")), + ) + + +def pickup_update_request( + payload: models.PickupUpdateRequest, + settings: provider_utils.Settings, +) -> lib.Serializable: + options = lib.units.Options( + payload.options, + option_type=lib.units.create_enum( + "PickupOptions", + # fmt: off + { + "sapient_shipment_id": lib.OptionEnum("shipment_id"), + "sapient_slot_reservation_id": lib.OptionEnum("SlotReservationId"), + "sapient_bring_my_label": lib.OptionEnum("BringMyLabel"), + }, + # fmt: on + ), + ) + + # map data to convert karrio model to sapient specific type + request = sapient.PickupRequestType( + SlotDate=payload.pickup_date, + SlotReservationId=options.slot_reservation_id.state, + BringMyLabel=lib.identity( + options.sapient_bring_my_label.state + if options.sapient_bring_my_label.state is not None + else False + ), + ) + + return lib.Serializable( + request, + lib.to_dict, + dict(shipmentId=payload.options.shipment_id.state), + ) diff --git a/modules/connectors/sapient/karrio/providers/sapient/shipment/__init__.py b/modules/connectors/sapient/karrio/providers/sapient/shipment/__init__.py new file mode 100644 index 0000000000..e4e8dfb1f9 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/shipment/__init__.py @@ -0,0 +1,9 @@ + +from karrio.providers.sapient.shipment.create import ( + parse_shipment_response, + shipment_request, +) +from karrio.providers.sapient.shipment.cancel import ( + parse_shipment_cancel_response, + shipment_cancel_request, +) diff --git a/modules/connectors/sapient/karrio/providers/sapient/shipment/cancel.py b/modules/connectors/sapient/karrio/providers/sapient/shipment/cancel.py new file mode 100644 index 0000000000..9f83aba657 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/shipment/cancel.py @@ -0,0 +1,56 @@ +import typing +import karrio.lib as lib +import karrio.core.models as models +import karrio.providers.sapient.error as error +import karrio.providers.sapient.utils as provider_utils +import karrio.providers.sapient.units as provider_units + + +def parse_shipment_cancel_response( + _response: lib.Deserializable[dict], + settings: provider_utils.Settings, +) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]: + response = _response.deserialize() + messages = error.parse_error_response(response, settings) + success = response.get("ok") is True + + confirmation = ( + models.ConfirmationDetails( + carrier_id=settings.carrier_id, + carrier_name=settings.carrier_name, + operation="Cancel Shipment", + success=success, + ) + if success + else None + ) + + return confirmation, messages + + +def shipment_cancel_request( + payload: models.ShipmentCancelRequest, + settings: provider_utils.Settings, +) -> lib.Serializable: + options = lib.units.Options( + payload.options, + option_type=lib.units.create_enum( + "PickupOptions", + # fmt: off + { + "reason": lib.OptionEnum("Reason"), + "shipment_ids": lib.OptionEnum("ShipmentIds"), + }, + # fmt: on + ), + ) + + # map data to convert karrio model to sapient specific type + request = dict( + Status="Cancel", + ShipmentIds=lib.identity( + options.shipment_ids.state or [payload.shipment_identifier] + ), + ) + + return lib.Serializable(request, lib.to_dict) diff --git a/modules/connectors/sapient/karrio/providers/sapient/shipment/create.py b/modules/connectors/sapient/karrio/providers/sapient/shipment/create.py new file mode 100644 index 0000000000..b5c7befbdf --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/shipment/create.py @@ -0,0 +1,229 @@ +"""Karrio SAPIENT shipment API implementation.""" + +import karrio.schemas.sapient.shipment_requests as sapient +import karrio.schemas.sapient.shipment_response as shipping + +import typing +import datetime +import karrio.lib as lib +import karrio.core.units as units +import karrio.core.models as models +import karrio.providers.sapient.error as error +import karrio.providers.sapient.utils as provider_utils +import karrio.providers.sapient.units as provider_units + + +def parse_shipment_response( + _response: lib.Deserializable[dict], + settings: provider_utils.Settings, +) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]: + response = _response.deserialize() + + messages = error.parse_error_response(response, settings) + shipment = lib.identity( + _extract_details(response, settings, _response.ctx) + if "Packages" in response + else None + ) + + return shipment, messages + + +def _extract_details( + data: dict, + settings: provider_utils.Settings, + ctx: dict = None, +) -> models.ShipmentDetails: + details = lib.to_object(shipping.ShipmentResponseType, data) + tracking_numbers = [_.TrackingNumber for _ in details.Packages] + shipment_ids = [_.ShipmentId for _ in details.Packages] + label = details.Labels + + return models.ShipmentDetails( + carrier_id=settings.carrier_id, + carrier_name=settings.carrier_name, + tracking_number=tracking_numbers[0], + shipment_identifier=shipment_ids[0], + label_type=details.LabelFormat, + docs=models.Documents(label=label), + meta=dict( + carrier_tracking_link=lib.failsafe(lambda: details.Packages[0].TrackingUrl), + shipment_ids=shipment_ids, + sapient_carrier=ctx.get("carrier"), + sapient_shipment_id=shipment_ids[0], + ), + ) + + +def shipment_request( + payload: models.ShipmentRequest, + settings: provider_utils.Settings, +) -> lib.Serializable: + shipper = lib.to_address(payload.shipper) + recipient = lib.to_address(payload.recipient) + return_address = lib.to_address(payload.return_address) + packages = lib.to_packages(payload.parcels) + service = provider_units.ShippingService.map(payload.service).value_or_key + carrier = provider_units.ShippingService.carrier(service) or settings.carrier_code + options = lib.to_shipping_options( + payload.options, + package_options=packages.options, + initializer=provider_units.shipping_options_initializer, + ) + customs = lib.to_customs_info( + payload.customs, + weight_unit="KG", + shipper=payload.shipper, + recipient=payload.recipient, + ) + commodities: units.Products = lib.identity( + customs.commodities if any(payload.customs) else packages.items + ) + + # map data to convert karrio model to sapient specific type + request = sapient.ShipmentRequestType( + ShipmentInformation=sapient.ShipmentInformationType( + ContentType=lib.identity("DOX" if packages.is_document else "NDX"), + Action="Process", + LabelFormat=provider_units.LabelType.map(payload.label_type).value or "PDF", + ServiceCode=service or "CRL1", + DescriptionOfGoods=packages.description, + ShipmentDate=lib.fdate( + options.shipment_date.state or datetime.datetime.now(), + format="%Y-%m-%d", + ), + CurrencyCode=options.currency.state or "GBP", + WeightUnitOfMeasure="KG", + DimensionsUnitOfMeasure="CM", + ContainerId=options.sapient_container_id.state, + DeclaredWeight=packages.weight.KG, + BusinessTransactionType=options.sapient_business_transaction_type.state, + ), + Shipper=sapient.ShipperType( + Address=sapient.AddressType( + ContactName=shipper.name, + CompanyName=shipper.company_name, + ContactEmail=shipper.email, + ContactPhone=shipper.phone_number, + Line1=shipper.address_line1, + Line2=shipper.address_line2, + Line3=None, + Town=shipper.city, + Postcode=shipper.postal_code, + County=None, + CountryCode=shipper.country_code, + ), + ShippingAccountId=None, + ShippingLocationId=None, + Reference1=payload.reference, + DepartmentNumber=None, + EoriNumber=customs.options.eori_number.state, + VatNumber=lib.identity( + customs.options.vat_registration_number.state or shipper.tax_id + ), + ), + Destination=sapient.DestinationType( + Address=sapient.AddressType( + ContactName=recipient.name, + CompanyName=recipient.company_name, + ContactEmail=recipient.email, + ContactPhone=recipient.phone_number, + Line1=recipient.address_line1, + Line2=recipient.address_line2, + Line3=None, + Town=recipient.city, + Postcode=recipient.postal_code, + County=None, + CountryCode=recipient.country_code, + ), + EoriNumber=None, + VatNumber=recipient.tax_id, + ), + CarrierSpecifics=sapient.CarrierSpecificsType( + ServiceLevel=None, + EbayVtn=options.ebay_vtn.state, + ServiceEnhancements=[ + sapient.ServiceEnhancementType( + Code=option.code, + SafeplaceLocation=lib.identity( + option.state if option.code == "Safeplace" else None + ), + ) + for _, option in options.items() + if _ not in provider_units.CUSTOM_OPTIONS + ], + ), + ReturnToSender=lib.identity( + sapient.ReturnToSenderType( + Address=sapient.AddressType( + ContactName=return_address.name, + CompanyName=return_address.company_name, + ContactEmail=return_address.email, + ContactPhone=return_address.phone_number, + Line1=return_address.address_line1, + Line2=return_address.address_line2, + Line3=None, + Town=return_address.city, + Postcode=return_address.postal_code, + County=None, + CountryCode=return_address.country_code, + ), + ) + if payload.return_address + else None + ), + Packages=[ + sapient.PackageType( + PackageType=lib.identity( + provider_units.PackagingType.map(package.packaging_type).value + or "Parcel" + ), + PackageOccurrence=index, + DeclaredWeight=package.weight.KG, + Dimensions=sapient.DimensionsType( + Length=package.length.CM, + Width=package.width.CM, + Height=package.height.CM, + ), + DeclaredValue=package.total_value, + ) + for index, package in typing.cast( + typing.List[typing.Tuple[int, units.Package]], + enumerate(packages, start=1), + ) + ], + Items=[ + sapient.ItemType( + SkuCode=item.sku, + PackageOccurrence=index, + Quantity=item.quantity, + Description=item.title or item.description, + Value=item.value_amount, + Weight=item.weight.KG, + HSCode=item.hs_code, + CountryOfOrigin=item.origin_country, + ) + for index, item in enumerate(commodities, start=1) + ], + Customs=lib.identity( + sapient.CustomsType( + ReasonForExport=provider_units.CustomsContentType.map( + customs.content_type + ).value, + Incoterms=customs.incoterm, + PreRegistrationNumber=customs.options.sapient_pre_registration_number.state, + PreRegistrationType=customs.options.sapient_pre_registration_type.state, + ShippingCharges=None, + OtherCharges=options.insurance.state, + QuotedLandedCost=None, + InvoiceNumber=customs.invoice, + InvoiceDate=lib.fdate(customs.invoice_date, format="%Y-%m-%d"), + ExportLicenceRequired=None, + Airn=customs.options.sapient_airn.state, + ) + if payload.customs + else None + ), + ) + + return lib.Serializable(request, lib.to_dict, dict(carrier=carrier)) diff --git a/modules/connectors/sapient/karrio/providers/sapient/units.py b/modules/connectors/sapient/karrio/providers/sapient/units.py new file mode 100644 index 0000000000..f9bca4ce55 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/units.py @@ -0,0 +1,627 @@ +import karrio.lib as lib +import karrio.core.units as units +import karrio.core.models as models + + +class LabelType(lib.StrEnum): + """Carrier specific label type""" + + PDF = "PDF" + PNG = "PNG" + ZPL203DPI = "ZPL203DPI" + ZPL300DPI = "ZPL300DPI" + + """ Unified Label type mapping """ + + ZPL = ZPL300DPI + + +class CustomsContentType(lib.StrEnum): + gift = "Gift" + commercial_sample = "Commercial Sample" + documents = "Documents" + sale_of_goods = "Sale of Goods" + return_of_goods = "Return of Goods" + mixed_content = "Mixed Content" + other = "Other" + + """ Unified Customs content type mapping """ + + sample = commercial_sample + merchandise = sale_of_goods + return_merchandise = return_of_goods + + +class PackagingType(lib.StrEnum): + """Carrier specific packaging type""" + + Letter = "Letter" + LargeLetter = "LargeLetter" + Parcel = "Parcel" + PrintedPapers = "PrintedPapers" + + """ Unified Packaging type mapping """ + envelope = Letter + pak = LargeLetter + tube = Parcel + pallet = Parcel + small_box = Parcel + medium_box = Parcel + your_packaging = Parcel + + +class ShippingService(lib.StrEnum): + """Carrier specific services""" + + # fmt: off + sapient_royal_mail_hm_forces_mail = "BF1" + sapient_royal_mail_hm_forces_signed_for = "BF2" + sapient_royal_mail_hm_forces_special_delivery_500 = "BF7" + sapient_royal_mail_hm_forces_special_delivery_1000 = "BF8" + sapient_royal_mail_hm_forces_special_delivery_2500 = "BF9" + sapient_royal_mail_international_business_personal_correspondence_max_sort_residue_ll = "BG1" + sapient_royal_mail_international_business_mail_ll_max_sort_residue_standard = "BG2" + sapient_royal_mail_international_business_personal_correspondence_max_sort_residue_l = "BP1" + sapient_royal_mail_international_business_mail_l_max_sort_residue_standard = "BP2" + sapient_royal_mail_international_business_printed_matter_packet = "BPI" + sapient_royal_mail_1st_class = "BPL1" + sapient_royal_mail_2nd_class = "BPL2" + sapient_royal_mail_1st_class_signed_for = "BPR1" + sapient_royal_mail_2nd_class_signed_for = "BPR2" + sapient_royal_mail_international_business_parcel_priority_country_priced_boxable = "BXB" + sapient_royal_mail_international_business_parcel_tracked_country_priced_boxable_extra_comp = "BXC" + sapient_royal_mail_international_business_parcel_priority_country_priced_boxable_ddp = "BXD" + sapient_royal_mail_international_business_parcel_tracked_country_priced_boxable_ddp = "BXE" + sapient_royal_mail_international_business_parcel_tracked_country_priced_boxable = "BXF" + sapient_royal_mail_24_standard_signed_for_parcel_daily_rate_service = "CRL1" + sapient_royal_mail_48_standard_signed_for_parcel_daily_rate_service = "CRL2" + sapient_royal_mail_international_business_parcels_zero_sort_priority = "DE4" + sapient_royal_mail_international_business_parcels_zero_sort_priority_DE = "DE6" + sapient_royal_mail_de_import_standard_24_parcel = "DEA" + sapient_royal_mail_de_import_standard_24_parcel_DE = "DEB" + sapient_royal_mail_de_import_standard_24_ll = "DEC" + sapient_royal_mail_de_import_standard_48_ll = "DED" + sapient_royal_mail_de_import_to_eu_tracked_signed_ll = "DEE" + sapient_royal_mail_de_import_to_eu_max_sort_ll = "DEG" + sapient_royal_mail_de_import_to_eu_tracked_parcel = "DEI" + sapient_royal_mail_de_import_to_eu_tracked_signed_parcel = "DEJ" + sapient_royal_mail_de_import_to_eu_tracked_high_vol_ll = "DEK" + sapient_royal_mail_de_import_to_eu_max_sort_parcel = "DEM" + sapient_royal_mail_international_business_mail_ll_country_priced_priority = "DG4" + sapient_royal_mail_international_business_personal_correspondence_l_priority_untracked = "DP3" + sapient_royal_mail_international_business_mail_ll_country_sort_priority = "DP6" + sapient_royal_mail_international_business_parcels = "DW1" + sapient_royal_mail_international_business_parcels_tracked_country_priced_extra_territorial_office_of_exchange = "ETA" + sapient_royal_mail_international_business_parcels_tracked_signed_country_priced_extra_territorial_office_of_exchange = "ETB" + sapient_royal_mail_international_business_parcels_zero_sort_priority_extra_territorial_office_of_exchange = "ETC" + sapient_royal_mail_international_business_mail_tracked_ll_country_priced_extra_territorial_office_of_exchange = "ETD" + sapient_royal_mail_international_business_mail_tracked_signed_ll_country_priced_extra_territorial_office_of_exchange = "ETE" + sapient_royal_mail_international_business_mail_ll_country_priced_priority_extra_territorial_office_of_exchange = "ETF" + sapient_royal_mail_international_tracked_parcels_0_30kg_extra_territorial_office_of_exchange_e = "ETG" + sapient_royal_mail_international_tracked_parcels_0_30kg_extra_comp_extra_territorial_office_of_exchange_e = "ETH" + sapient_royal_mail_international_tracked_parcels_0_30kg_extra_territorial_office_of_exchange_c = "ETI" + sapient_royal_mail_international_tracked_parcels_0_30kg_extra_comp_extra_territorial_office_of_exchange_c = "ETJ" + sapient_royal_mail_international_business_personal_correspondence_l_priority_untracked_extra_territorial_office_of_exchange = "ETK" + sapient_royal_mail_international_business_personal_correspondence_l_tracked_high_vol_country_priced_extra_territorial_office_of_exchange = "ETL" + sapient_royal_mail_international_business_personal_correspondence_l_tracked_signed_high_vol_country_priced_extra_territorial_office_of_exchange = "ETM" + sapient_royal_mail_international_business_personal_correspondence_signed_l_high_vol_country_priced_extra_territorial_office_of_exchange = "ETN" + sapient_royal_mail_international_business_personal_correspondence_ll_country_sort_priority_extra_territorial_office_of_exchange = "ETO" + sapient_royal_mail_international_business_personal_correspondence_tracked_ll_high_vol_extra_comp_country_priced_extra_territorial_office_of_exchange = "ETP" + sapient_royal_mail_international_business_personal_correspondence_tracked_signed_ll_high_vol_extra_comp_country_priced_extra_territorial_office_of_exchange = "ETQ" + sapient_royal_mail_international_business_personal_correspondence_signed_ll_extra_compensation_country_priced_extra_territorial_office_of_exchange = "ETR" + sapient_royal_mail_24_standard_signed_for_large_letter_flat_rate_service = "FS1" + sapient_royal_mail_48_standard_signed_for_large_letter_flat_rate_service = "FS2" + sapient_royal_mail_24_presorted_ll = "FS7" + sapient_royal_mail_48_presorted_ll = "FS8" + sapient_royal_mail_international_tracked_parcels_0_30kg = "HVB" + sapient_royal_mail_international_business_tracked_express_npc = "HVD" + sapient_royal_mail_international_tracked_parcels_0_30kg_extra_comp = "HVE" + sapient_royal_mail_international_tracked_parcels_0_30kg_c_prio = "HVK" + sapient_royal_mail_international_tracked_parcels_0_30kg_xcomp_c_prio = "HVL" + sapient_royal_mail_international_business_parcels_zone_sort_priority_service = "IE1" + sapient_royal_mail_international_business_mail_large_letter_zone_sort_priority = "IG1" + sapient_royal_mail_international_business_mail_large_letter_zone_sort_priority_machine = "IG4" + sapient_royal_mail_international_business_mail_letters_zone_sort_priority = "IP1" + sapient_royal_mail_import_de_tracked_returns_24 = "ITA" + sapient_royal_mail_import_de_tracked_returns_48 = "ITB" + sapient_royal_mail_import_de_tracked_24_letter_boxable_high_volume = "ITC" + sapient_royal_mail_import_de_tracked_48_letter_boxable_high_volume = "ITD" + sapient_royal_mail_import_de_tracked_48_letter_boxable = "ITE" + sapient_royal_mail_import_de_tracked_24_letter_boxable = "ITF" + sapient_royal_mail_import_de_tracked_48_high_volume = "ITL" + sapient_royal_mail_import_de_tracked_24_high_volume = "ITM" + sapient_royal_mail_import_de_tracked_24 = "ITN" + sapient_royal_mail_de_import_to_eu_signed_parcel = "ITR" + sapient_royal_mail_import_de_tracked_48 = "ITS" + sapient_royal_mail_international_business_parcels_print_direct_priority = "MB1" + sapient_royal_mail_international_business_parcels_print_direct_standard = "MB2" + sapient_royal_mail_international_business_parcels_signed_extra_compensation_country_priced = "MP0" + sapient_royal_mail_international_business_parcels_tracked_zone_sort = "MP1" + sapient_royal_mail_international_business_parcels_tracked_extra_comp_zone_sort = "MP4" + sapient_royal_mail_international_business_parcels_signed_zone_sort = "MP5" + sapient_royal_mail_international_business_parcels_signed_extra_compensation_zone_sort = "MP6" + sapient_royal_mail_international_business_parcels_tracked_country_priced = "MP7" + sapient_royal_mail_international_business_parcels_tracked_extra_comp_country_priced = "MP8" + sapient_royal_mail_international_business_parcels_signed_country_priced = "MP9" + sapient_royal_mail_international_business_mail_tracked_high_vol_country_priced = "MPL" + sapient_royal_mail_international_business_mail_tracked_signed_high_vol_country_priced = "MPM" + sapient_royal_mail_international_business_mail_signed_high_vol_country_priced = "MPN" + sapient_royal_mail_international_business_mail_tracked_high_vol_extra_comp_country_priced = "MPO" + sapient_royal_mail_international_business_mail_tracked_signed_high_vol_extra_comp_country_priced = "MPP" + sapient_royal_mail_international_business_parcel_tracked_boxable_country_priced = "MPR" + sapient_royal_mail_international_business_parcels_tracked_signed_zone_sort = "MTA" + sapient_royal_mail_international_business_parcels_tracked_signed_extra_compensation_zone_sort = "MTB" + sapient_royal_mail_international_business_mail_tracked_signed_zone_sort = "MTC" + sapient_royal_mail_international_business_parcels_tracked_signed_country_priced = "MTE" + sapient_royal_mail_international_business_parcels_tracked_signed_extra_compensation_country_priced = "MTF" + sapient_royal_mail_international_business_mail_tracked_signed_country_priced = "MTG" + sapient_royal_mail_international_business_mail_tracked_zone_sort = "MTI" + sapient_royal_mail_international_business_mail_tracked_country_priced = "MTK" + sapient_royal_mail_international_business_mail_signed_zone_sort = "MTM" + sapient_royal_mail_international_business_mail_signed_country_priced = "MTO" + sapient_royal_mail_international_business_mail_signed_extra_compensation_country_priced = "MTP" + sapient_royal_mail_international_business_parcels_tracked_direct_ireland_country = "MTS" + sapient_royal_mail_international_business_parcels_tracked_signed_ddp = "MTV" + sapient_royal_mail_international_standard_on_account = "OLA" + sapient_royal_mail_international_economy_on_account = "OLS" + sapient_royal_mail_international_signed_on_account = "OSA" + sapient_royal_mail_international_signed_on_account_extra_comp = "OSB" + sapient_royal_mail_international_tracked_on_account = "OTA" + sapient_royal_mail_international_tracked_on_account_extra_comp = "OTB" + sapient_royal_mail_international_tracked_signed_on_account = "OTC" + sapient_royal_mail_international_tracked_signed_on_account_extra_comp = "OTD" + sapient_royal_mail_48_ll_flat_rate = "PK0" + sapient_royal_mail_24_standard_signed_for_parcel_sort8_flat_rate_service = "PK1" + sapient_royal_mail_48_standard_signed_for_parcel_sort8_flat_rate_service = "PK2" + sapient_royal_mail_24_standard_signed_for_parcel_sort8_daily_rate_service = "PK3" + sapient_royal_mail_48_standard_signed_for_parcel_sort8_daily_rate_service = "PK4" + sapient_royal_mail_24_presorted_p = "PK7" + sapient_royal_mail_48_presorted_p = "PK8" + sapient_royal_mail_24_ll_flat_rate = "PK9" + sapient_royal_mail_rm24_presorted_p_annual_flat_rate = "PKB" + sapient_royal_mail_rm48_presorted_p_annual_flat_rate = "PKD" + sapient_royal_mail_rm48_presorted_ll_annual_flat_rate = "PKK" + sapient_royal_mail_rm24_presorted_ll_annual_flat_rate = "PKM" + sapient_royal_mail_24_standard_signed_for_packetpost_flat_rate_service = "PPF1" + sapient_royal_mail_48_standard_signed_for_packetpost_flat_rate_service = "PPF2" + sapient_royal_mail_parcelpost_flat_rate_annual = "PPJ1" + sapient_royal_mail_parcelpost_flat_rate_annual_PPJ = "PPJ2" + sapient_royal_mail_rm24_ll_annual_flat_rate = "PPS" + sapient_royal_mail_rm48_ll_annual_flat_rate = "PPT" + sapient_royal_mail_international_business_personal_correspondence_max_sort_l = "PS5" + sapient_royal_mail_international_business_mail_large_letter_max_sort_priority_service = "PS7" + sapient_royal_mail_international_business_mail_letters_max_sort_standard = "PSA" + sapient_royal_mail_international_business_mail_large_letter_max_sort_standard_service = "PSB" + sapient_royal_mail_48_sort8p_annual_flat_rate = "RM0" + sapient_royal_mail_24_ll_daily_rate = "RM1" + sapient_royal_mail_24_p_daily_rate = "RM2" + sapient_royal_mail_48_ll_daily_rate = "RM3" + sapient_royal_mail_48_p_daily_rate = "RM4" + sapient_royal_mail_24_p_flat_rate = "RM5" + sapient_royal_mail_48_p_flat_rate = "RM6" + sapient_royal_mail_24_sort8_ll_annual_flat_rate = "RM7" + sapient_royal_mail_24_sort8_p_annual_flat_rate = "RM8" + sapient_royal_mail_48_sort8_ll_annual_flat_rate = "RM9" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_750 = "SD1" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_1000 = "SD2" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_2500 = "SD3" + sapient_royal_mail_special_delivery_guaranteed_by_9am_750 = "SD4" + sapient_royal_mail_special_delivery_guaranteed_by_9am_1000 = "SD5" + sapient_royal_mail_special_delivery_guaranteed_by_9am_2500 = "SD6" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_id_750 = "SDA" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_id_1000 = "SDB" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_id_2500 = "SDC" + sapient_royal_mail_special_delivery_guaranteed_by_9am_id_750 = "SDE" + sapient_royal_mail_special_delivery_guaranteed_by_9am_id_1000 = "SDF" + sapient_royal_mail_special_delivery_guaranteed_by_9am_id_2500 = "SDG" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_age_750 = "SDH" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_age_1000 = "SDJ" + sapient_royal_mail_special_delivery_guaranteed_by_1pm_age_2500 = "SDK" + sapient_royal_mail_special_delivery_guaranteed_by_9am_age_750 = "SDM" + sapient_royal_mail_special_delivery_guaranteed_by_9am_age_1000 = "SDN" + sapient_royal_mail_special_delivery_guaranteed_by_9am_age_2500 = "SDQ" + sapient_royal_mail_special_delivery_guaranteed_age_750 = "SDV" + sapient_royal_mail_special_delivery_guaranteed_age_1000 = "SDW" + sapient_royal_mail_special_delivery_guaranteed_age_2500 = "SDX" + sapient_royal_mail_special_delivery_guaranteed_id_750 = "SDY" + sapient_royal_mail_special_delivery_guaranteed_id_1000 = "SDZ" + sapient_royal_mail_special_delivery_guaranteed_id_2500 = "SEA" + sapient_royal_mail_special_delivery_guaranteed_750 = "SEB" + sapient_royal_mail_special_delivery_guaranteed_1000 = "SEC" + sapient_royal_mail_special_delivery_guaranteed_2500 = "SED" + sapient_royal_mail_1st_class_standard_signed_for_letters_daily_rate_service = "STL1" + sapient_royal_mail_2nd_class_standard_signed_for_letters_daily_rate_service = "STL2" + sapient_royal_mail_tracked_24_high_volume_signature_age = "TPA" + sapient_royal_mail_tracked_48_high_volume_signature_age = "TPB" + sapient_royal_mail_tracked_24_signature_age = "TPC" + sapient_royal_mail_tracked_48_signature_age = "TPD" + sapient_royal_mail_tracked_48_high_volume_signature_no_signature = "TPL" + sapient_royal_mail_tracked_24_high_volume_signature_no_signature = "TPM" + sapient_royal_mail_tracked_24_signature_no_signature = "TPN" + sapient_royal_mail_tracked_48_signature_no_signature = "TPS" + sapient_royal_mail_tracked_letter_boxable_48_high_volume_signature_no_signature = "TRL" + sapient_royal_mail_tracked_letter_boxable_24_high_volume_signature_no_signature = "TRM" + sapient_royal_mail_tracked_letter_boxable_24_signature_no_signature = "TRN" + sapient_royal_mail_tracked_letter_boxable_48_signature_no_signature = "TRS" + sapient_royal_mail_tracked_returns_24 = "TSN" + sapient_royal_mail_tracked_returns_48 = "TSS" + sapient_royal_mail_international_business_parcels_zero_sort_priority_WE = "WE1" + sapient_royal_mail_international_business_mail_large_letter_zero_sort_priority = "WG1" + sapient_royal_mail_international_business_mail_large_letter_zero_sort_priority_machine = "WG4" + sapient_royal_mail_international_business_mail_letters_zero_sort_priority = "WP1" + # fmt: on + + @classmethod + def carrier(cls, value: str) -> str: + return next( + (_["CarrierCode"] for _ in SAPIENT_CARRIERS if _["alias"] in value), None + ) + + +class ShippingOption(lib.Enum): + """Carrier specific options""" + + # sapient_option = lib.OptionEnum("code") + sapient_CL1 = lib.OptionEnum("CL1", float) + sapient_CL2 = lib.OptionEnum("CL2", float) + sapient_CL3 = lib.OptionEnum("CL3", float) + sapient_CL4 = lib.OptionEnum("CL4", float) + sapient_CL5 = lib.OptionEnum("CL5", float) + sapient_signed = lib.OptionEnum("Signed", bool) + sapient_SMS = lib.OptionEnum("SMS", bool) + sapient_email = lib.OptionEnum("Email", bool) + sapient_safeplace = lib.OptionEnum("Safeplace", bool) + sapient_localcollect = lib.OptionEnum("LocalCollect", bool) + sapient_customs_email = lib.OptionEnum("CustomsEmail") + sapient_customs_phone = lib.OptionEnum("CustomsPhone") + sapient_safeplace_location = lib.OptionEnum("SafeplaceLocation") + + """ Custom options """ + sapient_container_id = lib.OptionEnum("ContainerId") + sapient_business_transaction_type = lib.OptionEnum("BusinessTransactionType") + + """ Unified Option type mapping """ + insurance = sapient_CL1 + signature_confirmation = sapient_signed + hold_at_location = sapient_localcollect + + +def shipping_options_initializer( + options: dict, + package_options: units.ShippingOptions = None, +) -> units.ShippingOptions: + """ + Apply default values to the given options. + """ + + if package_options is not None: + options.update(package_options.content) + + def items_filter(key: str) -> bool: + return key in ShippingOption # type: ignore + + return units.ShippingOptions(options, ShippingOption, items_filter=items_filter) + + +class TrackingStatus(lib.Enum): + on_hold = ["on_hold"] + delivered = ["delivered"] + in_transit = ["in_transit"] + delivery_failed = ["delivery_failed"] + delivery_delayed = ["delivery_delayed"] + out_for_delivery = ["out_for_delivery"] + ready_for_pickup = ["ready_for_pickup"] + + +SAPIENT_CARRIERS = [ + { + "CarrierCode": "DX", + "Name": "DX", + "Description": "DX – provides create shipment, print labels, print manifest, and view item tracking sent via the DX network.", + "alias": "dx", + }, + { + "CarrierCode": "EVRI", + "Name": "EVRi", + "Description": "Produces labels for shipments sent via the Evri network.", + "alias": "evri", + }, + { + "CarrierCode": "RM", + "Name": "Royal Mail", + "Description": "Produces labels, required documentation, billing management for shipments sent via the Royal Mail network.", + "alias": "royal_mail", + }, + { + "CarrierCode": "UPS", + "Name": "UPS", + "Description": "Provides shipping labels, tracking services, and comprehensive shipping solutions for shipments sent via the UPS network.", + "alias": "ups", + }, + { + "CarrierCode": "YODEL", + "Name": "Yodel", + "Description": "Produces labels, PDF manifests, Pre-advice files for shipments sent via the Yodel network.", + "alias": "yodel", + }, +] + + +CUSTOM_OPTIONS = [ + ShippingOption.sapient_container_id, + ShippingOption.sapient_business_transaction_type, +] + +SERVICES_DATA = [ + ["BF1", "HM Forces Mail"], + ["BF2", "HM Forces Signed For"], + ["BF7", "HM Forces Special Delivery (£500)"], + ["BF8", "HM Forces Special Delivery (£1000)"], + ["BF9", "HM Forces Special Delivery (£2500)"], + ["BG1", "International Business Personal Correspondence Max Sort Residue (LL)"], + ["BG2", "International Business Mail (LL) Max Sort Residue Standard"], + ["BP1", "International Business Personal Correspondence Max Sort Residue (L)"], + ["BP2", "International Business Mail (L) Max Sort Residue Standard"], + ["BPI", "International Business Printed Matter Packet"], + ["BPL1", "Royal Mail 1st Class"], + ["BPL2", "Royal Mail 2nd Class"], + ["BPR1", "Royal Mail 1st Class Signed For"], + ["BPR2", "Royal Mail 2nd Class Signed For"], + ["BXB", "International Business Parcel Priority Country Priced Boxable"], + ["BXC", "International Business Parcel Tracked Country Priced Boxable Extra Comp"], + ["BXD", "International Business Parcel Priority Country Priced Boxable DDP "], + ["BXE", "International Business Parcel Tracked Country Priced Boxable DDP"], + ["BXF", "International Business Parcel Tracked Country Priced Boxable"], + ["CRL1", "Royal Mail 24 Standard/Signed For (Parcel - Daily Rate Service)"], + ["CRL2", "Royal Mail 48 Standard/Signed For (Parcel - Daily Rate Service)"], + ["DE4", "International Business Parcels Zero Sort Priority"], + ["DE6", "International Business Parcels Zero Sort Priority"], + ["DEA", "DE Import Standard 24 Parcel"], + ["DEB", "DE Import Standard 24 Parcel"], + ["DEC", "DE Import Standard 24 (LL)"], + ["DED", "DE Import Standard 48 (LL)"], + ["DEE", "DE Import to EU Tracked & Signed (LL)"], + ["DEG", "DE Import to EU Max Sort (LL)"], + ["DEI", "DE Import to EU Tracked Parcel"], + ["DEJ", "DE Import to EU Tracked & Signed Parcel"], + ["DEK", "DE Import to EU Tracked High Vol (LL)"], + ["DEM", "DE Import to EU Max Sort Parcel"], + ["DG4", "International Business Mail (LL) Country Priced Priority"], + ["DP3", "International Business Personal Correspondence (L) Priority Untracked"], + ["DP6", "International Business Mail (LL) Country Sort Priority"], + ["DW1", "International Business Parcels"], + [ + "ETA", + "International Business Parcels Tracked Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETB", + "International Business Parcels Tracked & Signed Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETC", + "International Business Parcels Zero Sort Priority Extra-Territorial Office of Exchange", + ], + [ + "ETD", + "International Business Mail Tracked (LL) Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETE", + "International Business Mail Tracked & Signed (LL) Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETF", + "International Business Mail (LL) Country Priced Priority Extra-Territorial Office of Exchange", + ], + [ + "ETG", + "International Tracked Parcels 0-30kg Extra-Territorial Office of Exchange - E", + ], + [ + "ETH", + "International Tracked Parcels 0-30kg Extra Comp Extra-Territorial Office of Exchange E", + ], + [ + "ETI", + "International Tracked Parcels 0-30kg Extra-Territorial Office of Exchange - C", + ], + [ + "ETJ", + "International Tracked Parcels 0-30kg Extra Comp Extra-Territorial Office of Exchange C", + ], + [ + "ETK", + "International Business Personal Correspondence (L) Priority Untracked Extra-Territorial Office of Exchange", + ], + [ + "ETL", + "International Business Personal Correspondence (L) Tracked High Vol. Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETM", + "International Business Personal Correspondence (L) Tracked & Signed High Vol. Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETN", + "International Business Personal Correspondence Signed (L) High Vol. Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETO", + "International Business Personal Correspondence (LL) Country Sort Priority Extra-Territorial Office of Exchange", + ], + [ + "ETP", + "International Business Personal Correspondence Tracked (LL) High Vol. Extra Comp Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETQ", + "International Business Personal Correspondence Tracked & Signed (LL) High Vol. Extra Comp Country Priced Extra-Territorial Office of Exchange", + ], + [ + "ETR", + "International Business Personal Correspondence Signed (LL) Extra Compensation Country Priced Extra-Territorial Office of Exchange", + ], + ["FS1", "Royal Mail 24 Standard/Signed For Large Letter (Flat Rate Service)"], + ["FS2", "Royal Mail 48 Standard/Signed For Large Letter (Flat Rate Service)"], + ["FS7", "Royal Mail 24 (Presorted) (LL)"], + ["FS8", "Royal Mail 48 (Presorted) (LL)"], + ["HVB", "International Tracked Parcels 0-30kg"], + ["HVD", "International Business Tracked Express NPC"], + ["HVE", "International Tracked Parcels 0-30kg Extra Comp"], + ["HVK", "International Tracked Parcels 0-30kg C Prio"], + ["HVL", "International Tracked Parcels 0-30kg XComp C Prio"], + ["IE1", "International Business Parcels Zone Sort Priority Service"], + ["IG1", "International Business Mail Large Letter Zone Sort Priority"], + ["IG4", "International Business Mail Large Letter Zone Sort Priority Machine"], + ["IP1", "International Business Mail Letters Zone Sort Priority"], + ["ITA", "Import DE Tracked Returns 24"], + ["ITB", "Import DE Tracked Returns 48"], + ["ITC", "Import DE Tracked 24 Letter-boxable High Volume"], + ["ITD", "Import DE Tracked 48 Letter-boxable High Volume"], + ["ITE", "Import DE Tracked 48 Letter-boxable"], + ["ITF", "Import DE Tracked 24 Letter-boxable"], + ["ITL", "Import DE Tracked 48 High Volume"], + ["ITM", "Import DE Tracked 24 High Volume"], + ["ITN", "Import DE Tracked 24"], + ["ITR", "DE Import to EU Signed Parcel"], + ["ITS", "Import DE Tracked 48"], + ["MB1", "International Business Parcels Print Direct Priority"], + ["MB2", "International Business Parcels Print Direct Standard"], + ["MP0", "International Business Parcels Signed Extra Compensation Country Priced"], + ["MP1", "International Business Parcels Tracked Zone Sort"], + ["MP4", "International Business Parcels Tracked Extra Comp Zone Sort"], + ["MP5", "International Business Parcels Signed Zone Sort"], + ["MP6", "International Business Parcels Signed Extra Compensation Zone Sort"], + ["MP7", "International Business Parcels Tracked Country Priced"], + ["MP8", "International Business Parcels Tracked Extra Comp Country Priced"], + ["MP9", "International Business Parcels Signed Country Priced"], + ["MPL", "International Business Mail Tracked High Vol. Country Priced"], + ["MPM", "International Business Mail Tracked & Signed High Vol. Country Priced"], + ["MPN", "International Business Mail Signed High Vol. Country Priced"], + ["MPO", "International Business Mail Tracked High Vol. Extra Comp Country Priced"], + [ + "MPP", + "International Business Mail Tracked & Signed High Vol. Extra Comp Country Priced", + ], + ["MPR", "International Business Parcel Tracked Boxable Country Priced"], + ["MTA", "International Business Parcels Tracked & Signed Zone Sort"], + [ + "MTB", + "International Business Parcels Tracked & Signed Extra Compensation Zone Sort", + ], + ["MTC", "International Business Mail Tracked & Signed Zone Sort"], + ["MTE", "International Business Parcels Tracked & Signed Country Priced"], + [ + "MTF", + "International Business Parcels Tracked & Signed Extra Compensation Country Priced", + ], + ["MTG", "International Business Mail Tracked & Signed Country Priced"], + ["MTI", "International Business Mail Tracked Zone Sort"], + ["MTK", "International Business Mail Tracked Country Priced"], + ["MTM", "International Business Mail Signed Zone Sort"], + ["MTO", "International Business Mail Signed Country Priced"], + ["MTP", "International Business Mail Signed Extra Compensation Country Priced"], + ["MTS", "International Business Parcels Tracked Direct Ireland Country"], + ["MTV", "International Business Parcels Tracked & Signed DDP"], + ["OLA", "International Standard On Account"], + ["OLS", "International Economy On Account"], + ["OSA", "International Signed On Account"], + ["OSB", "International Signed On Account Extra Comp"], + ["OTA", "International Tracked On Account"], + ["OTB", "International Tracked On Account Extra Comp"], + ["OTC", "International Tracked & Signed On Account"], + ["OTD", "International Tracked & Signed On Account Extra Comp"], + ["PK0", "Royal Mail 48 (LL) Flat Rate"], + ["PK1", "Royal Mail 24 Standard/Signed For (Parcel - Sort8 - Flat Rate Service)"], + ["PK2", "Royal Mail 48 Standard/Signed For (Parcel - Sort8 - Flat Rate Service)"], + ["PK3", "Royal Mail 24 Standard/Signed For (Parcel - Sort8 - Daily Rate Service)"], + ["PK4", "Royal Mail 48 Standard/Signed For (Parcel - Sort8 - Daily Rate Service)"], + ["PK7", "Royal Mail 24 (Presorted) (P)"], + ["PK8", "Royal Mail 48 (Presorted) (P)"], + ["PK9", "Royal Mail 24 (LL) Flat Rate"], + ["PKB", "RM24 (Presorted) (P) Annual Flat Rate"], + ["PKD", "RM48 (Presorted) (P) Annual Flat Rate"], + ["PKK", "RM48 (Presorted) (LL) Annual Flat Rate"], + ["PKM", "RM24 (Presorted) (LL) Annual Flat Rate"], + ["PPF1", "Royal Mail 24 Standard/Signed For (Packetpost- Flat Rate Service)"], + ["PPF2", "Royal Mail 48 Standard/Signed For (Packetpost- Flat Rate Service)"], + ["PPJ1", "Parcelpost Flat Rate (Annual)"], + ["PPJ2", "Parcelpost Flat Rate (Annual)"], + ["PPS", "RM24 (LL) Annual Flat Rate"], + ["PPT", "RM48 (LL) Annual Flat Rate"], + ["PS5", "International Business Personal Correspondence Max Sort (L)"], + ["PS7", "International Business Mail Large Letter Max Sort Priority Service"], + ["PSA", "International Business Mail Letters Max Sort Standard"], + ["PSB", "International Business Mail Large Letter Max Sort Standard Service"], + ["RM0", "Royal Mail 48 (Sort8)(P) Annual Flat Rate"], + ["RM1", "Royal Mail 24 (LL) Daily Rate"], + ["RM2", "Royal Mail 24 (P) Daily Rate"], + ["RM3", "Royal Mail 48 (LL) Daily Rate"], + ["RM4", "Royal Mail 48 (P) Daily Rate"], + ["RM5", "Royal Mail 24 (P) Flat Rate"], + ["RM6", "Royal Mail 48 (P) Flat Rate"], + ["RM7", "Royal Mail 24 (SORT8) (LL) Annual Flat Rate"], + ["RM8", "Royal Mail 24 (SORT8) (P) Annual Flat Rate"], + ["RM9", "Royal Mail 48 (SORT8) (LL) Annual Flat Rate"], + ["SD1", "Special Delivery Guaranteed By 1PM (£750)"], + ["SD2", "Special Delivery Guaranteed By 1PM (£1000)"], + ["SD3", "Special Delivery Guaranteed By 1PM (£2500)"], + ["SD4", "Special Delivery Guaranteed By 9AM (£750)"], + ["SD5", "Special Delivery Guaranteed By 9AM (£1000)"], + ["SD6", "Special Delivery Guaranteed By 9AM (£2500)"], + ["SDA", "Special Delivery Guaranteed By 1PM (ID) (£750)"], + ["SDB", "Special Delivery Guaranteed By 1PM (ID) (£1000)"], + ["SDC", "Special Delivery Guaranteed By 1PM (ID) (£2500)"], + ["SDE", "Special Delivery Guaranteed By 9AM (ID) (£750)"], + ["SDF", "Special Delivery Guaranteed By 9AM (ID) (£1000)"], + ["SDG", "Special Delivery Guaranteed By 9AM (ID) (£2500)"], + ["SDH", "Special Delivery Guaranteed By 1PM (AGE) (£750)"], + ["SDJ", "Special Delivery Guaranteed By 1PM (AGE) (£1000)"], + ["SDK", "Special Delivery Guaranteed By 1PM (AGE) (£2500)"], + ["SDM", "Special Delivery Guaranteed By 9AM (AGE) (£750)"], + ["SDN", "Special Delivery Guaranteed By 9AM (AGE) (£1000)"], + ["SDQ", "Special Delivery Guaranteed By 9AM (AGE) (£2500)"], + ["SDV", "Special Delivery Guaranteed (AGE) (£750)"], + ["SDW", "Special Delivery Guaranteed (AGE) (£1000)"], + ["SDX", "Special Delivery Guaranteed (AGE) (£2500)"], + ["SDY", "Special Delivery Guaranteed (ID) (£750)"], + ["SDZ", "Special Delivery Guaranteed (ID) (£1000)"], + ["SEA", "Special Delivery Guaranteed (ID) (£2500)"], + ["SEB", "Special Delivery Guaranteed (£750)"], + ["SEC", "Special Delivery Guaranteed (£1000)"], + ["SED", "Special Delivery Guaranteed (£2500)"], + ["STL1", "Royal Mail 1st Class Standard/Signed For (Letters - Daily Rate service)"], + ["STL2", "Royal Mail 2nd Class Standard/Signed For (Letters - Daily Rate service)"], + ["TPA", "Tracked 24 High Volume Signature (AGE)"], + ["TPB", "Tracked 48 High Volume Signature (AGE)"], + ["TPC", "Tracked 24 Signature (AGE)"], + ["TPD", "Tracked 48 Signature (AGE)"], + ["TPL", "Tracked 48 High Volume Signature / No Signature"], + ["TPM", "Tracked 24 High Volume Signature / No Signature"], + ["TPN", "Tracked 24 Signature / No Signature"], + ["TPS", "Tracked 48 Signature / No Signature"], + ["TRL", "Tracked Letter-Boxable 48 High Volume Signature / No Signature"], + ["TRM", "Tracked Letter-Boxable 24 High Volume Signature / No Signature"], + ["TRN", "Tracked Letter-Boxable 24 Signature / No Signature"], + ["TRS", "Tracked Letter-Boxable 48 Signature / No Signature"], + ["TSN", "Tracked Returns 24"], + ["TSS", "Tracked Returns 48"], + ["WE1", "International Business Parcels Zero Sort Priority"], + ["WG1", "International Business Mail Large Letter Zero Sort Priority"], + ["WG4", "International Business Mail Large Letter Zero Sort Priority Machine"], + ["WP1", "International Business Mail Letters Zero Sort Priority"], +] + +DEFAULT_SERVICES = [ + models.ServiceLevel( + service_name=__, + service_code=ShippingService.map(_).name_or_key, + carrier_service_code=_, + currency="GBP", + domicile=not "International" in __, + international="International" in __, + zones=[models.ServiceZone(rate=2.80)], + ) + for _, __ in SERVICES_DATA +] diff --git a/modules/connectors/sapient/karrio/providers/sapient/utils.py b/modules/connectors/sapient/karrio/providers/sapient/utils.py new file mode 100644 index 0000000000..afaeba9da8 --- /dev/null +++ b/modules/connectors/sapient/karrio/providers/sapient/utils.py @@ -0,0 +1,99 @@ +import base64 +import datetime +import karrio.lib as lib +import karrio.core as core +import karrio.core.errors as errors + + +class Settings(core.Settings): + """SAPIENT connection settings.""" + + # Add carrier specific api connection properties here + client_id: str + client_secret: str + shipping_account_id: str + carrier_code: str = "RM" + + @property + def carrier_name(self): + return "sapient" + + @property + def server_url(self): + return "https://api.intersoftsapient.net" + + # """uncomment the following code block to expose a carrier tracking url.""" + # @property + # def tracking_url(self): + # return "https://www.carrier.com/tracking?tracking-id={}" + + # """uncomment the following code block to implement the Basic auth.""" + # @property + # def authorization(self): + # pair = "%s:%s" % (self.username, self.password) + # return base64.b64encode(pair.encode("utf-8")).decode("ascii") + + @property + def connection_config(self) -> lib.units.Options: + return lib.to_connection_config( + self.config or {}, + option_type=ConnectionConfig, + ) + + """uncomment the following code block to implement the oauth login.""" + + @property + def access_token(self): + """Retrieve the access_token using the client_id|client_secret pair + or collect it from the cache if an unexpired access_token exist. + """ + cache_key = f"{self.carrier_name}|{self.client_id}|{self.client_secret}" + now = datetime.datetime.now() + datetime.timedelta(minutes=30) + + auth = self.connection_cache.get(cache_key) or {} + token = auth.get("access_token") + expiry = lib.to_date(auth.get("expiry"), current_format="%Y-%m-%d %H:%M:%S") + + if token is not None and expiry is not None and expiry > now: + return token + + self.connection_cache.set(cache_key, lambda: login(self)) + new_auth = self.connection_cache.get(cache_key) + + return new_auth["access_token"] + + +"""uncomment the following code block to implement the oauth login.""" + + +def login(settings: Settings): + import karrio.providers.sapient.error as error + + result = lib.request( + url=f"{settings.server_url}/connect/token", + method="POST", + headers={"content-Type": "application/x-www-form-urlencoded"}, + data=lib.to_query_string( + dict( + grant_type="client_credentials", + client_id=settings.client_id, + client_secret=settings.client_secret, + ) + ), + ) + + response = lib.to_dict(result) + messages = error.parse_error_response(response, settings) + + if any(messages): + raise errors.ShippingSDKError(messages) + + expiry = datetime.datetime.now() + datetime.timedelta( + seconds=float(response.get("expires_in", 0)) + ) + return {**response, "expiry": lib.fdatetime(expiry)} + + +class ConnectionConfig(lib.Enum): + shipping_options = lib.OptionEnum("shipping_options", list) + shipping_services = lib.OptionEnum("shipping_services", list) diff --git a/modules/connectors/sapient/karrio/schemas/sapient/__init__.py b/modules/connectors/sapient/karrio/schemas/sapient/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/connectors/sapient/karrio/schemas/sapient/error_response.py b/modules/connectors/sapient/karrio/schemas/sapient/error_response.py new file mode 100644 index 0000000000..3428a1ec28 --- /dev/null +++ b/modules/connectors/sapient/karrio/schemas/sapient/error_response.py @@ -0,0 +1,16 @@ +from attr import s +from typing import Optional, List +from jstruct import JList + + +@s(auto_attribs=True) +class ErrorType: + Message: Optional[str] = None + Cause: Optional[str] = None + ErrorCode: Optional[str] = None + + +@s(auto_attribs=True) +class ErrorResponseType: + Message: Optional[str] = None + Errors: List[ErrorType] = JList[ErrorType] diff --git a/modules/connectors/sapient/karrio/schemas/sapient/pickup_request.py b/modules/connectors/sapient/karrio/schemas/sapient/pickup_request.py new file mode 100644 index 0000000000..cb413d5d6d --- /dev/null +++ b/modules/connectors/sapient/karrio/schemas/sapient/pickup_request.py @@ -0,0 +1,9 @@ +from attr import s +from typing import Optional + + +@s(auto_attribs=True) +class PickupRequestType: + SlotReservationId: Optional[str] = None + SlotDate: Optional[str] = None + BringMyLabel: Optional[bool] = None diff --git a/modules/connectors/sapient/karrio/schemas/sapient/pickup_response.py b/modules/connectors/sapient/karrio/schemas/sapient/pickup_response.py new file mode 100644 index 0000000000..c39f397538 --- /dev/null +++ b/modules/connectors/sapient/karrio/schemas/sapient/pickup_response.py @@ -0,0 +1,8 @@ +from attr import s +from typing import Optional + + +@s(auto_attribs=True) +class PickupResponseType: + CollectionOrderId: Optional[str] = None + CollectionDate: Optional[str] = None diff --git a/modules/connectors/sapient/karrio/schemas/sapient/shipment_requests.py b/modules/connectors/sapient/karrio/schemas/sapient/shipment_requests.py new file mode 100644 index 0000000000..edb37b3b93 --- /dev/null +++ b/modules/connectors/sapient/karrio/schemas/sapient/shipment_requests.py @@ -0,0 +1,125 @@ +from attr import s +from typing import Optional, List +from jstruct import JList, JStruct + + +@s(auto_attribs=True) +class ServiceEnhancementType: + Code: Optional[str] = None + SafeplaceLocation: Optional[str] = None + + +@s(auto_attribs=True) +class CarrierSpecificsType: + ServiceLevel: Optional[str] = None + EbayVtn: Optional[str] = None + ServiceEnhancements: List[ServiceEnhancementType] = JList[ServiceEnhancementType] + + +@s(auto_attribs=True) +class CustomsType: + ReasonForExport: Optional[str] = None + Incoterms: Optional[str] = None + PreRegistrationNumber: Optional[str] = None + PreRegistrationType: Optional[str] = None + ShippingCharges: Optional[float] = None + OtherCharges: Optional[int] = None + QuotedLandedCost: Optional[float] = None + InvoiceNumber: Optional[str] = None + InvoiceDate: Optional[str] = None + ExportLicenceRequired: Optional[bool] = None + Airn: Optional[str] = None + + +@s(auto_attribs=True) +class AddressType: + ContactName: Optional[str] = None + CompanyName: Optional[str] = None + ContactEmail: Optional[str] = None + ContactPhone: Optional[str] = None + Line1: Optional[str] = None + Line2: Optional[str] = None + Line3: Optional[str] = None + Town: Optional[str] = None + Postcode: Optional[str] = None + County: Optional[str] = None + CountryCode: Optional[str] = None + + +@s(auto_attribs=True) +class DestinationType: + Address: Optional[AddressType] = JStruct[AddressType] + EoriNumber: Optional[str] = None + VatNumber: Optional[str] = None + + +@s(auto_attribs=True) +class ItemType: + SkuCode: Optional[str] = None + PackageOccurrence: Optional[int] = None + Quantity: Optional[int] = None + Description: Optional[str] = None + Value: Optional[float] = None + Weight: Optional[float] = None + HSCode: Optional[str] = None + CountryOfOrigin: Optional[str] = None + + +@s(auto_attribs=True) +class DimensionsType: + Length: Optional[int] = None + Width: Optional[int] = None + Height: Optional[int] = None + + +@s(auto_attribs=True) +class PackageType: + PackageType: Optional[str] = None + PackageOccurrence: Optional[int] = None + DeclaredWeight: Optional[float] = None + Dimensions: Optional[DimensionsType] = JStruct[DimensionsType] + DeclaredValue: Optional[float] = None + + +@s(auto_attribs=True) +class ReturnToSenderType: + Address: Optional[AddressType] = JStruct[AddressType] + + +@s(auto_attribs=True) +class ShipmentInformationType: + ContentType: Optional[str] = None + Action: Optional[str] = None + LabelFormat: Optional[str] = None + ServiceCode: Optional[str] = None + DescriptionOfGoods: Optional[str] = None + ShipmentDate: Optional[str] = None + CurrencyCode: Optional[str] = None + WeightUnitOfMeasure: Optional[str] = None + DimensionsUnitOfMeasure: Optional[str] = None + ContainerId: Optional[str] = None + DeclaredWeight: Optional[float] = None + BusinessTransactionType: Optional[str] = None + + +@s(auto_attribs=True) +class ShipperType: + Address: Optional[AddressType] = JStruct[AddressType] + ShippingAccountId: Optional[str] = None + ShippingLocationId: Optional[str] = None + Reference1: Optional[str] = None + DepartmentNumber: Optional[str] = None + EoriNumber: Optional[str] = None + VatNumber: Optional[str] = None + + +@s(auto_attribs=True) +class ShipmentRequestType: + ShipmentInformation: Optional[ShipmentInformationType] = JStruct[ShipmentInformationType] + Shipper: Optional[ShipperType] = JStruct[ShipperType] + Destination: Optional[DestinationType] = JStruct[DestinationType] + CarrierSpecifics: Optional[CarrierSpecificsType] = JStruct[CarrierSpecificsType] + ReturnToSender: Optional[ReturnToSenderType] = JStruct[ReturnToSenderType] + Packages: List[PackageType] = JList[PackageType] + Items: List[ItemType] = JList[ItemType] + Customs: Optional[CustomsType] = JStruct[CustomsType] diff --git a/modules/connectors/sapient/karrio/schemas/sapient/shipment_response.py b/modules/connectors/sapient/karrio/schemas/sapient/shipment_response.py new file mode 100644 index 0000000000..6d1ef7c660 --- /dev/null +++ b/modules/connectors/sapient/karrio/schemas/sapient/shipment_response.py @@ -0,0 +1,24 @@ +from attr import s +from typing import Optional, List +from jstruct import JStruct, JList + + +@s(auto_attribs=True) +class CarrierDetailsType: + UniqueId: Optional[str] = None + + +@s(auto_attribs=True) +class PackageType: + CarrierDetails: Optional[CarrierDetailsType] = JStruct[CarrierDetailsType] + ShipmentId: Optional[str] = None + PackageOccurrence: Optional[int] = None + TrackingNumber: Optional[str] = None + CarrierTrackingUrl: Optional[str] = None + + +@s(auto_attribs=True) +class ShipmentResponseType: + Labels: Optional[str] = None + LabelFormat: Optional[str] = None + Packages: List[PackageType] = JList[PackageType] diff --git a/modules/connectors/sapient/schemas/error_response.json b/modules/connectors/sapient/schemas/error_response.json new file mode 100644 index 0000000000..7c26f5da7e --- /dev/null +++ b/modules/connectors/sapient/schemas/error_response.json @@ -0,0 +1,15 @@ +{ + "Message": "Invalid Request", + "Errors": [ + { + "Message": "The name maximum length is 50 characters.", + "Cause": "Destination.Address.ContactName", + "ErrorCode": "E1002" + }, + { + "Message": "The service code is required.", + "Cause": "ShipmentInformation.ServiceCode", + "ErrorCode": "E1001" + } + ] +} diff --git a/modules/connectors/sapient/schemas/pickup_request.json b/modules/connectors/sapient/schemas/pickup_request.json new file mode 100644 index 0000000000..a19f2f5704 --- /dev/null +++ b/modules/connectors/sapient/schemas/pickup_request.json @@ -0,0 +1,5 @@ +{ + "SlotReservationId": "1f3c991f-a6ff-4ffb-9292-17690d745992", + "SlotDate": "2024-06-17", + "BringMyLabel": false +} diff --git a/modules/connectors/sapient/schemas/pickup_response.json b/modules/connectors/sapient/schemas/pickup_response.json new file mode 100644 index 0000000000..243b02b4d7 --- /dev/null +++ b/modules/connectors/sapient/schemas/pickup_response.json @@ -0,0 +1,4 @@ +{ + "CollectionOrderId": "CC-W307-028741033", + "CollectionDate": "2023-07-04" +} diff --git a/modules/connectors/sapient/schemas/shipment_requests.json b/modules/connectors/sapient/schemas/shipment_requests.json new file mode 100644 index 0000000000..971ae10419 --- /dev/null +++ b/modules/connectors/sapient/schemas/shipment_requests.json @@ -0,0 +1,241 @@ +[ + { + "ShipmentInformation": { + "ContentType": "NDX", + "Action": "Process", + "LabelFormat": "PDF", + "ServiceCode": "CRL1", + "DescriptionOfGoods": "Clothes", + "ShipmentDate": "2024-06-17", + "CurrencyCode": "GBP", + "WeightUnitOfMeasure": "KG", + "DimensionsUnitOfMeasure": "CM", + "ContainerId": "South East" + }, + "Shipper": { + "Address": { + "ContactName": "Jane Smith", + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "607723456789", + "Line1": "Level 5", + "Line2": "Hashmoore House", + "Line3": "10 Sky Lane", + "Town": "Leatherhead", + "Postcode": "AA34 3AB", + "County": "Surrey", + "CountryCode": "GB" + }, + "ShippingAccountId": "32d2fe85-4b8e-4061-98d8-4942275a5ebd", + "ShippingLocationId": "e15f4578-9a15-46cd-9347-820efc963cd5", + "Reference1": "OrderRef56", + "DepartmentNumber": "0123456789", + "EoriNumber": "GB213456789000", + "VatNumber": "GB213456789" + }, + "Destination": { + "Address": { + "ContactName": "John Smith", + "ContactEmail": "john.smith@example.com", + "ContactPhone": "07123456789", + "Line1": "10 Sky Road", + "Town": "Woking", + "Postcode": "GU21 4TE", + "CountryCode": "GB" + }, + "EoriNumber": "GB123456789000", + "VatNumber": "GB123456789" + }, + "CarrierSpecifics": { + "ServiceLevel": "01", + "EbayVtn": "ebay1234abc", + "ServiceEnhancements": [ + { + "Code": "Signed" + } + ] + }, + "ReturnToSender": { + "Address": { + "ContactName": "Jane Smith", + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "07723456789", + "Line1": "Level 5", + "Line2": "Hashmoore House", + "Line3": "10 Sky Lane", + "Town": "Leatherhead", + "Postcode": "AA34 3AB", + "County": "Surrey", + "CountryCode": "GB" + } + }, + "Packages": [ + { + "PackageType": "Parcel", + "PackageOccurrence": 1, + "DeclaredWeight": 1.5, + "Dimensions": { + "Length": 40, + "Width": 30, + "Height": 20 + } + } + ], + "Items": [ + { + "SkuCode": "SKU123", + "PackageOccurrence": 1, + "Quantity": 1, + "Description": "White Mens Large T-shirt", + "Value": 19.99, + "Weight": 0.5, + "HSCode": "6109100010", + "CountryOfOrigin": "CN" + }, + { + "SkuCode": "SKU456", + "PackageOccurrence": 1, + "Quantity": 2, + "Description": "Black Mens Large Jumper", + "Value": 32.99, + "Weight": 0.3, + "HSCode": "6110113000", + "CountryOfOrigin": "CN" + } + ] + }, + { + "ShipmentInformation": { + "ContentType": "NDX", + "Action": "Process", + "LabelFormat": "PDF", + "ServiceCode": "OLA", + "DescriptionOfGoods": "Clothing", + "ShipmentDate": "2024-06-17", + "CurrencyCode": "GBP", + "WeightUnitOfMeasure": "KG", + "DimensionsUnitOfMeasure": "MM", + "ContainerId": "South East", + "DeclaredWeight": 1.5, + "BusinessTransactionType": "01" + }, + "Shipper": { + "Address": { + "ContactName": "Jane Smith", + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "607723456789", + "Line1": "Level 5", + "Line2": "Hashmoore House", + "Line3": "10 Sky Lane", + "Town": "Leatherhead", + "Postcode": "AA34 3AB", + "County": "Surrey", + "CountryCode": "GB" + }, + "ShippingAccountId": "1991b077-3934-4efc-b9cb-2a916436d3ae", + "ShippingLocationId": "f7f38476-3d11-4c8e-be61-20b158393401", + "Reference1": "OrderRef56", + "DepartmentNumber": "0123456789", + "EoriNumber": "GB213456789000", + "VatNumber": "GB213456789" + }, + "Destination": { + "Address": { + "ContactName": "John Smith", + "ContactEmail": "john.smith@example.com", + "CompanyName": "Company & Co.", + "ContactPhone": "07123456789", + "Line1": "10 Sky Road", + "Line2": "10 Sky Road", + "Line3": "", + "Town": "Sydney", + "Postcode": "2000", + "County": "NSW", + "CountryCode": "AU" + }, + "EoriNumber": "GB123456789000", + "VatNumber": "GB123456789" + }, + "CarrierSpecifics": { + "ServiceLevel": "02", + "EbayVtn": "ebay1234abc", + "ServiceEnhancements": [ + { + "Code": "CustomsEmail" + }, + { + "Code": "CustomsPhone" + }, + { + "Code": "Safeplace", + "SafeplaceLocation": "Under the doormat" + } + ] + }, + "Customs": { + "ReasonForExport": "Sale Of Goods", + "Incoterms": "DDU", + "PreRegistrationNumber": "0123456789", + "PreRegistrationType": "GST", + "ShippingCharges": 55.82, + "OtherCharges": 32, + "QuotedLandedCost": 82.74, + "InvoiceNumber": "INV-12345", + "InvoiceDate": "2024-06-17", + "ExportLicenceRequired": false, + "Airn": "231.002.999-00" + }, + "ReturnToSender": { + "Address": { + "ContactName": "Jane Smith", + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "07723456789", + "Line1": "Level 5", + "Line2": "Hashmoore House", + "Line3": "10 Sky Lane", + "Town": "Leatherhead", + "Postcode": "AA34 3AB", + "County": "Surrey", + "CountryCode": "GB" + } + }, + "Packages": [ + { + "PackageType": "Parcel", + "PackageOccurrence": 1, + "DeclaredWeight": 1.5, + "DeclaredValue": 98.99, + "Dimensions": { + "Length": 40, + "Width": 30, + "Height": 20 + } + } + ], + "Items": [ + { + "SkuCode": "SKU123", + "PackageOccurrence": 1, + "Quantity": 1, + "Description": "White Mens Large T-shirt", + "Value": 19.99, + "Weight": 0.5, + "HSCode": "6109100010", + "CountryOfOrigin": "CN" + }, + { + "SkuCode": "SKU456", + "PackageOccurrence": 1, + "Quantity": 2, + "Description": "Black Mens Large Jumper", + "Value": 32.99, + "Weight": 0.3, + "HSCode": "6110113000", + "CountryOfOrigin": "CN" + } + ] + } +] diff --git a/modules/connectors/sapient/schemas/shipment_response.json b/modules/connectors/sapient/schemas/shipment_response.json new file mode 100644 index 0000000000..850464b11a --- /dev/null +++ b/modules/connectors/sapient/schemas/shipment_response.json @@ -0,0 +1,15 @@ +{ + "Labels": "jVBERw0KGgoAAAANSUhEUgAA.....A4QAAAXcCAYAAAB6Q0CbAAAAAXNSR0IArs4", + "LabelFormat": "PDF", + "Packages": [ + { + "CarrierDetails": { + "UniqueId": "3A07033860010000B2268" + }, + "ShipmentId": "fa3bb603-2687-4b38-ba18-3264208446c6", + "PackageOccurrence": 1, + "TrackingNumber": "TT123456785GB", + "CarrierTrackingUrl": "https://www.royalmail.com/track-your-item#/tracking-results/TT123456785GB" + } + ] +} diff --git a/modules/connectors/sapient/setup.py b/modules/connectors/sapient/setup.py new file mode 100644 index 0000000000..8a780836b0 --- /dev/null +++ b/modules/connectors/sapient/setup.py @@ -0,0 +1,27 @@ + +"""Warning: This setup.py is only there for git install until poetry support git subdirectory""" +from setuptools import setup, find_namespace_packages + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup( + name="karrio.sapient", + version="2024.8", + description="Karrio - SAPIENT Shipping Extension", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/karrioapi/karrio", + author="karrio", + author_email="hello@karrio.io", + license="Apache-2.0", + packages=find_namespace_packages(exclude=["tests.*", "tests"]), + install_requires=["karrio"], + classifiers=[ + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + ], + zip_safe=False, + include_package_data=True, +) diff --git a/modules/connectors/sapient/tests/__init__.py b/modules/connectors/sapient/tests/__init__.py new file mode 100644 index 0000000000..261b3e035c --- /dev/null +++ b/modules/connectors/sapient/tests/__init__.py @@ -0,0 +1,2 @@ +from tests.sapient.test_pickup import * +from tests.sapient.test_shipment import * diff --git a/modules/connectors/sapient/tests/sapient/__init__.py b/modules/connectors/sapient/tests/sapient/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/connectors/sapient/tests/sapient/fixture.py b/modules/connectors/sapient/tests/sapient/fixture.py new file mode 100644 index 0000000000..1699d6c6fe --- /dev/null +++ b/modules/connectors/sapient/tests/sapient/fixture.py @@ -0,0 +1,25 @@ +import karrio +import datetime +import karrio.lib as lib + +expiry = datetime.datetime.now() + datetime.timedelta(days=1) +client_id = "client_id" +client_secret = "client_secret" +cached_auth = { + f"sapient|{client_id}|{client_secret}": dict( + access_token="access_token", + token_type="Bearer", + issued_at="1685542319575", + scope="mscapi.all", + expires_in="14399", + ) +} + +gateway = karrio.gateway["sapient"].create( + dict( + client_id="client_id", + client_secret="client_secret", + shipping_account_id="shipping_account_id", + ), + cache=lib.Cache(**cached_auth), +) diff --git a/modules/connectors/sapient/tests/sapient/test_pickup.py b/modules/connectors/sapient/tests/sapient/test_pickup.py new file mode 100644 index 0000000000..c74f86bb98 --- /dev/null +++ b/modules/connectors/sapient/tests/sapient/test_pickup.py @@ -0,0 +1,181 @@ +import unittest +from unittest.mock import patch, ANY +from .fixture import gateway +from tests import logger + +import karrio +import karrio.lib as lib +import karrio.core.models as models + + +class TestSAPIENTPickup(unittest.TestCase): + def setUp(self): + self.maxDiff = None + self.PickupRequest = models.PickupRequest(**PickupPayload) + self.PickupUpdateRequest = models.PickupUpdateRequest(**PickupUpdatePayload) + self.PickupCancelRequest = models.PickupCancelRequest(**PickupCancelPayload) + + def test_create_pickup_request(self): + request = gateway.mapper.create_pickup_request(self.PickupRequest) + + self.assertEqual(request.serialize(), PickupRequest) + + def test_create_update_pickup_request(self): + request = gateway.mapper.create_pickup_update_request(self.PickupUpdateRequest) + + self.assertEqual(request.serialize(), PickupUpdateRequest) + + def test_create_cancel_pickup_request(self): + request = gateway.mapper.create_cancel_pickup_request(self.PickupCancelRequest) + + self.assertEqual(request.serialize(), PickupCancelRequest) + + def test_create_pickup(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = "{}" + karrio.Pickup.schedule(self.PickupRequest).from_(gateway) + + self.assertEqual( + mock.call_args[1]["url"], + f"{gateway.settings.server_url}", + ) + + def test_update_pickup(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = "{}" + karrio.Pickup.update(self.PickupUpdateRequest).from_(gateway) + + self.assertEqual( + mock.call_args[1]["url"], + f"{gateway.settings.server_url}", + ) + + def test_cancel_pickup(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = "{}" + karrio.Pickup.cancel(self.PickupCancelRequest).from_(gateway) + + self.assertEqual( + mock.call_args[1]["url"], + f"{gateway.settings.server_url}", + ) + + def test_parse_pickup_response(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = PickupResponse + parsed_response = ( + karrio.Pickup.schedule(self.PickupRequest).from_(gateway).parse() + ) + + self.assertListEqual(lib.to_dict(parsed_response), ParsedPickupResponse) + + def test_parse_cancel_pickup_response(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = PickupCancelResponse + parsed_response = ( + karrio.Pickup.cancel(self.PickupCancelRequest).from_(gateway).parse() + ) + + self.assertListEqual( + lib.to_dict(parsed_response), ParsedCancelPickupResponse + ) + + +if __name__ == "__main__": + unittest.main() + + +PickupPayload = { + "pickup_date": "2013-10-19", + "ready_time": "10:20", + "closing_time": "09:20", + "instruction": "behind the front desk", + "address": { + "company_name": "ABC Corp.", + "address_line1": "1098 N Fraser Street", + "city": "Georgetown", + "postal_code": "29440", + "country_code": "US", + "person_name": "Tall Tom", + "phone_number": "8005554526", + "state_code": "SC", + }, + "parcels": [{"weight": 20, "weight_unit": "LB"}], + "options": {"usps_package_type": "FIRST-CLASS_PACKAGE_SERVICE"}, +} + +PickupUpdatePayload = { + "confirmation_number": "0074698052", + "pickup_date": "2013-10-19", + "ready_time": "10:20", + "closing_time": "09:20", + "instruction": "behind the front desk", + "address": { + "company_name": "ABC Corp.", + "address_line1": "1098 N Fraser Street", + "city": "Georgetown", + "postal_code": "29440", + "country_code": "US", + "person_name": "Tall Tom", + "phone_number": "8005554526", + "state_code": "SC", + }, + "parcels": [{"weight": 20, "weight_unit": "LB"}], + "options": {"usps_package_type": "FIRST-CLASS_PACKAGE_SERVICE"}, +} + +PickupCancelPayload = {"confirmation_number": "0074698052"} + +ParsedPickupResponse = [ + { + "carrier_id": "sapient", + "carrier_name": "sapient", + "confirmation_number": "string", + "pickup_date": "2019-08-24", + }, + [], +] + +ParsedCancelPickupResponse = [ + { + "carrier_id": "sapient", + "carrier_name": "sapient", + "operation": "Cancel Pickup", + "success": True, + }, + [], +] + + +PickupRequest = { + "SlotReservationId": "1f3c991f-a6ff-4ffb-9292-17690d745992", + "SlotDate": "2024-06-17", + "BringMyLabel": False, +} + + +PickupUpdateRequest = { + "SlotDate": "2024-06-17", + "BringMyLabel": True, +} + + +PickupCancelRequest = { + "shipmentId": "1f3c991f-a6ff-4ffb-9292-17690d745992", +} + + +PickupResponse = """{ + "CollectionOrderId": "CC-W307-028741033", + "CollectionDate": "2023-07-04" +} +""" + +PickupUpdateResponse = """{ + "CollectionOrderId": "CC-W307-028741033", + "CollectionDate": "2023-07-04" +} +""" + +PickupCancelResponse = """{"ok": true} +""" diff --git a/modules/connectors/sapient/tests/sapient/test_shipment.py b/modules/connectors/sapient/tests/sapient/test_shipment.py new file mode 100644 index 0000000000..9f8e3383d6 --- /dev/null +++ b/modules/connectors/sapient/tests/sapient/test_shipment.py @@ -0,0 +1,275 @@ +import unittest +from unittest.mock import patch, ANY +from .fixture import gateway +from tests import logger + +import karrio +import karrio.lib as lib +import karrio.core.models as models + + +class TestSAPIENTShipping(unittest.TestCase): + def setUp(self): + self.maxDiff = None + self.ShipmentRequest = models.ShipmentRequest(**ShipmentPayload) + self.ShipmentCancelRequest = models.ShipmentCancelRequest( + **ShipmentCancelPayload + ) + + def test_create_shipment_request(self): + request = gateway.mapper.create_shipment_request(self.ShipmentRequest) + + self.assertEqual(request.serialize(), ShipmentRequest) + + def test_create_cancel_shipment_request(self): + request = gateway.mapper.create_cancel_shipment_request( + self.ShipmentCancelRequest + ) + + self.assertEqual(request.serialize(), ShipmentCancelRequest) + + def test_create_shipment(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = "{}" + karrio.Shipment.create(self.ShipmentRequest).from_(gateway) + + self.assertEqual( + mock.call_args[1]["url"], + f"{gateway.settings.server_url}", + ) + + def test_cancel_shipment(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = "{}" + karrio.Shipment.cancel(self.ShipmentCancelRequest).from_(gateway) + + self.assertEqual( + mock.call_args[1]["url"], + f"{gateway.settings.server_url}", + ) + + def test_parse_shipment_response(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = ShipmentResponse + parsed_response = ( + karrio.Shipment.create(self.ShipmentRequest).from_(gateway).parse() + ) + + self.assertListEqual(lib.to_dict(parsed_response), ParsedShipmentResponse) + + def test_parse_cancel_shipment_response(self): + with patch("karrio.mappers.sapient.proxy.lib.request") as mock: + mock.return_value = ShipmentCancelResponse + parsed_response = ( + karrio.Shipment.cancel(self.ShipmentCancelRequest) + .from_(gateway) + .parse() + ) + + self.assertListEqual( + lib.to_dict(parsed_response), ParsedCancelShipmentResponse + ) + + +if __name__ == "__main__": + unittest.main() + + +ShipmentPayload = { + "shipper": { + "company_name": "TESTING COMPANY", + "address_line1": "17 VULCAN RD", + "city": "CANNING VALE", + "postal_code": "6155", + "country_code": "AU", + "person_name": "TEST USER", + "state_code": "WA", + "email": "test@gmail.com", + "phone_number": "(07) 3114 1499", + }, + "recipient": { + "company_name": "CGI", + "address_line1": "23 jardin private", + "city": "Ottawa", + "postal_code": "k1k 4t3", + "country_code": "CA", + "person_name": "Jain", + "state_code": "ON", + }, + "parcels": [ + { + "height": 50, + "length": 50, + "weight": 20, + "width": 12, + "dimension_unit": "CM", + "weight_unit": "KG", + } + ], + "service": "carrier_service", + "options": { + "signature_required": True, + }, + "reference": "#Order 11111", +} + +ShipmentCancelPayload = { + "shipment_identifier": "794947717776", +} + +ParsedShipmentResponse = [] + +ParsedCancelShipmentResponse = ParsedCancelShipmentResponse = [ + { + "carrier_id": "sapient", + "carrier_name": "sapient", + "operation": "Cancel Shipment", + "success": True, + }, + [], +] + + +ShipmentRequest = { + "ShipmentInformation": { + "ContentType": "NDX", + "Action": "Process", + "LabelFormat": "PDF", + "ServiceCode": "OLA", + "DescriptionOfGoods": "Clothing", + "ShipmentDate": "2024-06-17", + "CurrencyCode": "GBP", + "WeightUnitOfMeasure": "KG", + "DimensionsUnitOfMeasure": "MM", + "ContainerId": "South East", + "DeclaredWeight": 1.5, + "BusinessTransactionType": "01", + }, + "Shipper": { + "Address": { + "ContactName": "Jane Smith", + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "607723456789", + "Line1": "Level 5", + "Line2": "Hashmoore House", + "Line3": "10 Sky Lane", + "Town": "Leatherhead", + "Postcode": "AA34 3AB", + "County": "Surrey", + "CountryCode": "GB", + }, + "ShippingAccountId": "1991b077-3934-4efc-b9cb-2a916436d3ae", + "ShippingLocationId": "f7f38476-3d11-4c8e-be61-20b158393401", + "Reference1": "OrderRef56", + "DepartmentNumber": "0123456789", + "EoriNumber": "GB213456789000", + "VatNumber": "GB213456789", + }, + "Destination": { + "Address": { + "ContactName": "John Smith", + "ContactEmail": "john.smith@example.com", + "CompanyName": "Company & Co.", + "ContactPhone": "07123456789", + "Line1": "10 Sky Road", + "Line2": "10 Sky Road", + "Line3": "", + "Town": "Sydney", + "Postcode": "2000", + "County": "NSW", + "CountryCode": "AU", + }, + "EoriNumber": "GB123456789000", + "VatNumber": "GB123456789", + }, + "CarrierSpecifics": { + "ServiceLevel": "02", + "EbayVtn": "ebay1234abc", + "ServiceEnhancements": [ + {"Code": "CustomsEmail"}, + {"Code": "CustomsPhone"}, + {"Code": "Safeplace", "SafeplaceLocation": "Under the doormat"}, + ], + }, + "Customs": { + "ReasonForExport": "Sale Of Goods", + "Incoterms": "DDU", + "PreRegistrationNumber": "0123456789", + "PreRegistrationType": "GST", + "ShippingCharges": 55.82, + "OtherCharges": 32, + "QuotedLandedCost": 82.74, + "InvoiceNumber": "INV-12345", + "InvoiceDate": "2024-06-17", + "ExportLicenceRequired": False, + "Airn": "231.002.999-00", + }, + "ReturnToSender": { + "Address": { + "ContactName": "Jane Smith", + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "07723456789", + "Line1": "Level 5", + "Line2": "Hashmoore House", + "Line3": "10 Sky Lane", + "Town": "Leatherhead", + "Postcode": "AA34 3AB", + "County": "Surrey", + "CountryCode": "GB", + } + }, + "Packages": [ + { + "PackageType": "Parcel", + "PackageOccurrence": 1, + "DeclaredWeight": 1.5, + "DeclaredValue": 98.99, + "Dimensions": {"Length": 40, "Width": 30, "Height": 20}, + } + ], + "Items": [ + { + "SkuCode": "SKU123", + "PackageOccurrence": 1, + "Quantity": 1, + "Description": "White Mens Large T-shirt", + "Value": 19.99, + "Weight": 0.5, + "HSCode": "6109100010", + "CountryOfOrigin": "CN", + }, + { + "SkuCode": "SKU456", + "PackageOccurrence": 1, + "Quantity": 2, + "Description": "Black Mens Large Jumper", + "Value": 32.99, + "Weight": 0.3, + "HSCode": "6110113000", + "CountryOfOrigin": "CN", + }, + ], +} + +ShipmentCancelRequest = {"ShipmentId": "fa3bb603-2687-4b38-ba18-3264208446c6"} + +ShipmentResponse = """{ + "Labels": "jVBERw0KGgoAAAANSUhEUgAA.....A4QAAAXcCAYAAAB6Q0CbAAAAAXNSR0IArs4", + "LabelFormat": "PDF", + "Packages": [ + { + "CarrierDetails": { + "UniqueId": "3A07033860010000B2268" + }, + "ShipmentId": "fa3bb603-2687-4b38-ba18-3264208446c6", + "PackageOccurrence": 1, + "TrackingNumber": "TT123456785GB", + "CarrierTrackingUrl": "https://www.royalmail.com/track-your-item#/tracking-results/TT123456785GB" + } + ] +} +""" + +ShipmentCancelResponse = """{"ok": true}""" diff --git a/modules/core/karrio/server/providers/extension/models/dpd.py b/modules/core/karrio/server/providers/extension/models/dpd.py index ddc6db4afc..a96b8b8b40 100644 --- a/modules/core/karrio/server/providers/extension/models/dpd.py +++ b/modules/core/karrio/server/providers/extension/models/dpd.py @@ -16,6 +16,7 @@ class Meta: account_country_code = models.CharField( max_length=3, blank=True, null=True, choices=providers.COUNTRIES ) + services = models.ManyToManyField("ServiceLevel", blank=True) @property def carrier_name(self) -> str: diff --git a/modules/core/karrio/server/providers/extension/models/generic.py b/modules/core/karrio/server/providers/extension/models/generic.py index cecefa7d5c..e46836c2ee 100644 --- a/modules/core/karrio/server/providers/extension/models/generic.py +++ b/modules/core/karrio/server/providers/extension/models/generic.py @@ -18,7 +18,6 @@ class Meta: validators=[validators.RegexValidator(r"^[a-z0-9_]+$")], help_text="Unique carrier slug, lowercase alphanumeric characters and underscores only", ) - services = models.ManyToManyField("ServiceLevel", blank=True) label_template = models.OneToOneField( "LabelTemplate", null=True, blank=True, on_delete=models.CASCADE ) @@ -26,6 +25,7 @@ class Meta: account_country_code = models.CharField( max_length=3, null=True, blank=True, choices=providers.COUNTRIES ) + services = models.ManyToManyField("ServiceLevel", blank=True) @property def carrier_name(self) -> str: diff --git a/modules/core/karrio/server/providers/extension/models/sapient.py b/modules/core/karrio/server/providers/extension/models/sapient.py new file mode 100644 index 0000000000..71894477ed --- /dev/null +++ b/modules/core/karrio/server/providers/extension/models/sapient.py @@ -0,0 +1,22 @@ +import django.db.models as models +import karrio.server.providers.models as providers + + +@providers.has_rate_sheet("sapient") +class SAPIENTSettings(providers.Carrier): + class Meta: + db_table = "sapient-settings" + verbose_name = "SAPIENT Settings" + verbose_name_plural = "SAPIENT Settings" + + client_id = models.CharField(max_length=100) + client_secret = models.CharField(max_length=100) + shipping_account_id = models.CharField(max_length=100) + services = models.ManyToManyField("ServiceLevel", blank=True) + + @property + def carrier_name(self) -> str: + return "sapient" + + +SETTINGS = SAPIENTSettings diff --git a/modules/sdk/karrio/core/models.py b/modules/sdk/karrio/core/models.py index 8c54aef20a..dfade53d79 100644 --- a/modules/sdk/karrio/core/models.py +++ b/modules/sdk/karrio/core/models.py @@ -181,8 +181,8 @@ class PickupRequest: address: Address = JStruct[Address, REQUIRED] parcels: List[Parcel] = JList[Parcel] - instruction: str = None package_location: str = None + instruction: str = None options: Dict = {} metadata: Dict = {} @@ -199,8 +199,8 @@ class PickupUpdateRequest: address: Address = JStruct[Address, REQUIRED] parcels: List[Parcel] = JList[Parcel] - instruction: str = None package_location: str = None + instruction: str = None options: Dict = {} From e7b0e39995bee38e3dc2523b3a91dc133e6fea13 Mon Sep 17 00:00:00 2001 From: Daniel K Date: Sun, 11 Aug 2024 17:33:52 -0700 Subject: [PATCH 2/3] feat: consolidate sapient integration with unit tests --- .../sapient/karrio/mappers/sapient/proxy.py | 5 +- .../karrio/providers/sapient/pickup/create.py | 7 +- .../karrio/providers/sapient/pickup/update.py | 15 +- .../providers/sapient/shipment/create.py | 13 +- .../sapient/karrio/providers/sapient/units.py | 10 +- .../sapient/tests/sapient/fixture.py | 1 + .../sapient/tests/sapient/test_pickup.py | 43 ++- .../sapient/tests/sapient/test_shipment.py | 292 ++++++++++-------- modules/sdk/karrio/core/models.py | 1 + 9 files changed, 233 insertions(+), 154 deletions(-) diff --git a/modules/connectors/sapient/karrio/mappers/sapient/proxy.py b/modules/connectors/sapient/karrio/mappers/sapient/proxy.py index c258e26b7c..906dd905c2 100644 --- a/modules/connectors/sapient/karrio/mappers/sapient/proxy.py +++ b/modules/connectors/sapient/karrio/mappers/sapient/proxy.py @@ -60,11 +60,12 @@ def modify_pickup(self, request: lib.Serializable) -> lib.Deserializable[str]: if response.deserialize()["ok"]: response = self.schedule_pickup(request) - return lib.Deserializable(response, lib.to_dict) + return lib.Deserializable(response, lib.to_dict, request.ctx) def cancel_pickup(self, request: lib.Serializable) -> lib.Deserializable[str]: + payload = request.serialize() response = lib.request( - url=f"{self.settings.server_url}/v4/collections/{request.serialize()['carrier']}/{request.serialize()['shipmentId']}/cancel", + url=f"{self.settings.server_url}/v4/collections/{payload['carrier']}/{payload['shipmentId']}/cancel", trace=self.trace_as("json"), method="PUT", headers={ diff --git a/modules/connectors/sapient/karrio/providers/sapient/pickup/create.py b/modules/connectors/sapient/karrio/providers/sapient/pickup/create.py index fa462dd0c1..6248d8ba9a 100644 --- a/modules/connectors/sapient/karrio/providers/sapient/pickup/create.py +++ b/modules/connectors/sapient/karrio/providers/sapient/pickup/create.py @@ -67,7 +67,7 @@ def pickup_request( # map data to convert karrio model to sapient specific type request = sapient.PickupRequestType( SlotDate=payload.pickup_date, - SlotReservationId=options.slot_reservation_id.state, + SlotReservationId=options.sapient_slot_reservation_id.state, BringMyLabel=lib.identity( options.sapient_bring_my_label.state if options.sapient_bring_my_label.state is not None @@ -78,5 +78,8 @@ def pickup_request( return lib.Serializable( request, lib.to_dict, - dict(shipmentId=payload.options.shipment_id.state, carrier=carrier), + dict( + shipmentId=options.sapient_shipment_id.state, + carrier=options.sapient_carrier.state or settings.carrier_code, + ), ) diff --git a/modules/connectors/sapient/karrio/providers/sapient/pickup/update.py b/modules/connectors/sapient/karrio/providers/sapient/pickup/update.py index 40c2be4496..bf9f93416b 100644 --- a/modules/connectors/sapient/karrio/providers/sapient/pickup/update.py +++ b/modules/connectors/sapient/karrio/providers/sapient/pickup/update.py @@ -38,7 +38,10 @@ def _extract_details( carrier_name=settings.carrier_name, confirmation_number=details.CollectionOrderId, pickup_date=lib.fdate(details.CollectionDate), - meta=dict(shipment_id=ctx.get("shipmentId")), + meta=dict( + sapient_shipment_id=ctx.get("shipmentId"), + sapient_carrier=ctx.get("carrier"), + ), ) @@ -52,9 +55,10 @@ def pickup_update_request( "PickupOptions", # fmt: off { + "sapient_carrier": lib.OptionEnum("sapient_carrier"), "sapient_shipment_id": lib.OptionEnum("shipment_id"), "sapient_slot_reservation_id": lib.OptionEnum("SlotReservationId"), - "sapient_bring_my_label": lib.OptionEnum("BringMyLabel"), + "sapient_bring_my_label": lib.OptionEnum("BringMyLabel", bool), }, # fmt: on ), @@ -63,7 +67,7 @@ def pickup_update_request( # map data to convert karrio model to sapient specific type request = sapient.PickupRequestType( SlotDate=payload.pickup_date, - SlotReservationId=options.slot_reservation_id.state, + SlotReservationId=options.sapient_slot_reservation_id.state, BringMyLabel=lib.identity( options.sapient_bring_my_label.state if options.sapient_bring_my_label.state is not None @@ -74,5 +78,8 @@ def pickup_update_request( return lib.Serializable( request, lib.to_dict, - dict(shipmentId=payload.options.shipment_id.state), + dict( + shipmentId=options.sapient_shipment_id.state, + carrier=options.sapient_carrier.state or settings.carrier_code, + ), ) diff --git a/modules/connectors/sapient/karrio/providers/sapient/shipment/create.py b/modules/connectors/sapient/karrio/providers/sapient/shipment/create.py index b5c7befbdf..d61405407d 100644 --- a/modules/connectors/sapient/karrio/providers/sapient/shipment/create.py +++ b/modules/connectors/sapient/karrio/providers/sapient/shipment/create.py @@ -49,6 +49,7 @@ def _extract_details( meta=dict( carrier_tracking_link=lib.failsafe(lambda: details.Packages[0].TrackingUrl), shipment_ids=shipment_ids, + tracking_numbers=tracking_numbers, sapient_carrier=ctx.get("carrier"), sapient_shipment_id=shipment_ids[0], ), @@ -77,7 +78,7 @@ def shipment_request( recipient=payload.recipient, ) commodities: units.Products = lib.identity( - customs.commodities if any(payload.customs) else packages.items + customs.commodities if payload.customs else packages.items ) # map data to convert karrio model to sapient specific type @@ -90,7 +91,7 @@ def shipment_request( DescriptionOfGoods=packages.description, ShipmentDate=lib.fdate( options.shipment_date.state or datetime.datetime.now(), - format="%Y-%m-%d", + "%Y-%m-%d", ), CurrencyCode=options.currency.state or "GBP", WeightUnitOfMeasure="KG", @@ -140,8 +141,8 @@ def shipment_request( VatNumber=recipient.tax_id, ), CarrierSpecifics=sapient.CarrierSpecificsType( - ServiceLevel=None, - EbayVtn=options.ebay_vtn.state, + ServiceLevel=settings.connection_config.service_level.state or "02", + EbayVtn=options.sapient_ebay_vtn.state, ServiceEnhancements=[ sapient.ServiceEnhancementType( Code=option.code, @@ -199,7 +200,7 @@ def shipment_request( Quantity=item.quantity, Description=item.title or item.description, Value=item.value_amount, - Weight=item.weight.KG, + Weight=item.weight, HSCode=item.hs_code, CountryOfOrigin=item.origin_country, ) @@ -217,7 +218,7 @@ def shipment_request( OtherCharges=options.insurance.state, QuotedLandedCost=None, InvoiceNumber=customs.invoice, - InvoiceDate=lib.fdate(customs.invoice_date, format="%Y-%m-%d"), + InvoiceDate=lib.fdate(customs.invoice_date, "%Y-%m-%d"), ExportLicenceRequired=None, Airn=customs.options.sapient_airn.state, ) diff --git a/modules/connectors/sapient/karrio/providers/sapient/units.py b/modules/connectors/sapient/karrio/providers/sapient/units.py index f9bca4ce55..af0acdfb57 100644 --- a/modules/connectors/sapient/karrio/providers/sapient/units.py +++ b/modules/connectors/sapient/karrio/providers/sapient/units.py @@ -270,13 +270,13 @@ class ShippingOption(lib.Enum): sapient_signed = lib.OptionEnum("Signed", bool) sapient_SMS = lib.OptionEnum("SMS", bool) sapient_email = lib.OptionEnum("Email", bool) - sapient_safeplace = lib.OptionEnum("Safeplace", bool) sapient_localcollect = lib.OptionEnum("LocalCollect", bool) sapient_customs_email = lib.OptionEnum("CustomsEmail") sapient_customs_phone = lib.OptionEnum("CustomsPhone") - sapient_safeplace_location = lib.OptionEnum("SafeplaceLocation") + sapient_safeplace_location = lib.OptionEnum("Safeplace") """ Custom options """ + sapient_ebay_vtn = lib.OptionEnum("EbayVtn") sapient_container_id = lib.OptionEnum("ContainerId") sapient_business_transaction_type = lib.OptionEnum("BusinessTransactionType") @@ -346,10 +346,10 @@ class TrackingStatus(lib.Enum): }, ] - CUSTOM_OPTIONS = [ - ShippingOption.sapient_container_id, - ShippingOption.sapient_business_transaction_type, + ShippingOption.sapient_ebay_vtn.name, + ShippingOption.sapient_container_id.name, + ShippingOption.sapient_business_transaction_type.name, ] SERVICES_DATA = [ diff --git a/modules/connectors/sapient/tests/sapient/fixture.py b/modules/connectors/sapient/tests/sapient/fixture.py index 1699d6c6fe..8dcfb13946 100644 --- a/modules/connectors/sapient/tests/sapient/fixture.py +++ b/modules/connectors/sapient/tests/sapient/fixture.py @@ -12,6 +12,7 @@ issued_at="1685542319575", scope="mscapi.all", expires_in="14399", + expiry=expiry.strftime("%Y-%m-%d %H:%M:%S"), ) } diff --git a/modules/connectors/sapient/tests/sapient/test_pickup.py b/modules/connectors/sapient/tests/sapient/test_pickup.py index c74f86bb98..00d7d01490 100644 --- a/modules/connectors/sapient/tests/sapient/test_pickup.py +++ b/modules/connectors/sapient/tests/sapient/test_pickup.py @@ -37,17 +37,17 @@ def test_create_pickup(self): self.assertEqual( mock.call_args[1]["url"], - f"{gateway.settings.server_url}", + f"{gateway.settings.server_url}/v4/collections/RM/fa3bb603-2687-4b38-ba18-3264208446c6", ) def test_update_pickup(self): with patch("karrio.mappers.sapient.proxy.lib.request") as mock: - mock.return_value = "{}" + mock.side_effect = [PickupCancelResponse, "{}"] karrio.Pickup.update(self.PickupUpdateRequest).from_(gateway) self.assertEqual( mock.call_args[1]["url"], - f"{gateway.settings.server_url}", + f"{gateway.settings.server_url}/v4/collections/RM/fa3bb603-2687-4b38-ba18-3264208446c6", ) def test_cancel_pickup(self): @@ -57,7 +57,7 @@ def test_cancel_pickup(self): self.assertEqual( mock.call_args[1]["url"], - f"{gateway.settings.server_url}", + f"{gateway.settings.server_url}/v4/collections/RM/fa3bb603-2687-4b38-ba18-3264208446c6/cancel", ) def test_parse_pickup_response(self): @@ -101,7 +101,11 @@ def test_parse_cancel_pickup_response(self): "state_code": "SC", }, "parcels": [{"weight": 20, "weight_unit": "LB"}], - "options": {"usps_package_type": "FIRST-CLASS_PACKAGE_SERVICE"}, + "options": { + "sapient_slot_reservation_id": "1f3c991f-a6ff-4ffb-9292-17690d745992", + "sapient_shipment_id": "fa3bb603-2687-4b38-ba18-3264208446c6", + "sapient_carrier": "RM", + }, } PickupUpdatePayload = { @@ -121,17 +125,31 @@ def test_parse_cancel_pickup_response(self): "state_code": "SC", }, "parcels": [{"weight": 20, "weight_unit": "LB"}], - "options": {"usps_package_type": "FIRST-CLASS_PACKAGE_SERVICE"}, + "options": { + "sapient_shipment_id": "fa3bb603-2687-4b38-ba18-3264208446c6", + "sapient_carrier": "RM", + "sapient_bring_my_label": True, + }, } -PickupCancelPayload = {"confirmation_number": "0074698052"} +PickupCancelPayload = { + "confirmation_number": "0074698052", + "options": { + "sapient_shipment_id": "fa3bb603-2687-4b38-ba18-3264208446c6", + "sapient_carrier": "RM", + }, +} ParsedPickupResponse = [ { "carrier_id": "sapient", "carrier_name": "sapient", - "confirmation_number": "string", - "pickup_date": "2019-08-24", + "confirmation_number": "CC-W307-028741033", + "pickup_date": "2023-07-04", + "meta": { + "sapient_shipment_id": "fa3bb603-2687-4b38-ba18-3264208446c6", + "sapient_carrier": "RM", + }, }, [], ] @@ -149,19 +167,20 @@ def test_parse_cancel_pickup_response(self): PickupRequest = { "SlotReservationId": "1f3c991f-a6ff-4ffb-9292-17690d745992", - "SlotDate": "2024-06-17", + "SlotDate": "2013-10-19", "BringMyLabel": False, } PickupUpdateRequest = { - "SlotDate": "2024-06-17", + "SlotDate": "2013-10-19", "BringMyLabel": True, } PickupCancelRequest = { - "shipmentId": "1f3c991f-a6ff-4ffb-9292-17690d745992", + "carrier": "RM", + "shipmentId": "fa3bb603-2687-4b38-ba18-3264208446c6", } diff --git a/modules/connectors/sapient/tests/sapient/test_shipment.py b/modules/connectors/sapient/tests/sapient/test_shipment.py index 9f8e3383d6..3fdef64253 100644 --- a/modules/connectors/sapient/tests/sapient/test_shipment.py +++ b/modules/connectors/sapient/tests/sapient/test_shipment.py @@ -35,7 +35,7 @@ def test_create_shipment(self): self.assertEqual( mock.call_args[1]["url"], - f"{gateway.settings.server_url}", + f"{gateway.settings.server_url}/v4/shipments/RM", ) def test_cancel_shipment(self): @@ -45,7 +45,7 @@ def test_cancel_shipment(self): self.assertEqual( mock.call_args[1]["url"], - f"{gateway.settings.server_url}", + f"{gateway.settings.server_url}/v4/shipments/status", ) def test_parse_shipment_response(self): @@ -76,48 +76,117 @@ def test_parse_cancel_shipment_response(self): ShipmentPayload = { + "service": "carrier_service", "shipper": { - "company_name": "TESTING COMPANY", - "address_line1": "17 VULCAN RD", - "city": "CANNING VALE", - "postal_code": "6155", - "country_code": "AU", - "person_name": "TEST USER", - "state_code": "WA", - "email": "test@gmail.com", - "phone_number": "(07) 3114 1499", + "company_name": "Company & Co.", + "person_name": "Jane Smith", + "address_line1": "10 Sky Lane", + "address_line2": "Hashmoore House", + "city": "Leatherhead", + "postal_code": "AA34 3AB", + "country_code": "GB", + "person_name": "Jane Smith", + "state_code": "Surrey", + "phone_number": "607723456789", + "email": "email@server.com", }, "recipient": { - "company_name": "CGI", - "address_line1": "23 jardin private", - "city": "Ottawa", - "postal_code": "k1k 4t3", - "country_code": "CA", - "person_name": "Jain", - "state_code": "ON", + "company_name": "Company & Co.", + "person_name": "John Smith", + "address_line1": "10 Sky Road", + "address_line2": "10 Sky Road", + "city": "Sydney", + "postal_code": "2000", + "country_code": "AU", + "person_name": "John Smith", + "state_code": "NSW", + "phone_number": "07123456789", + "email": "john.smith@example.com", + }, + "return_address": { + "company_name": "Company & Co.", + "person_name": "John Smith", + "address_line1": "Level 5", + "address_line2": "Hashmoore House", + "city": "Leatherhead", + "postal_code": "AA34 3AB", + "country_code": "GB", + "person_name": "John Smith", + "state_code": "Surrey", + "phone_number": "07723456789", + "email": "email@server.com", }, "parcels": [ { - "height": 50, - "length": 50, - "weight": 20, - "width": 12, - "dimension_unit": "CM", - "weight_unit": "KG", + "weight": 1.5, + "length": 40, + "width": 30, + "height": 20, } ], - "service": "carrier_service", "options": { - "signature_required": True, + "declared_value": 98.99, + "sapient_customs_email": True, + "sapient_customs_phone": True, + "sapient_ebay_vtn": "ebay1234abc", + "sapient_safeplace_location": "Under the doormat", }, - "reference": "#Order 11111", + "customs": { + "content_type": "merchandise", + "incoterms": "DDU", + "invoice": "INV-12345", + "invoice_date": "2024-06-17", + "options": { + "eori_number": "GB213456789000", + "vat_registration_number": "GB123456789", + }, + "commodities": [ + { + "title": "White Mens Large T-shirt", + "quantity": 1, + "weight": 0.5, + "value_amount": 19.99, + "origin_country": "CN", + "hs_code": "6109100010", + "sku": "SKU123", + }, + { + "title": "Black Mens Large Jumper", + "quantity": 2, + "weight": 0.3, + "value_amount": 32.99, + "origin_country": "CN", + "hs_code": "6110113000", + "sku": "SKU456", + }, + ], + }, + "reference": "OrderRef56", } ShipmentCancelPayload = { - "shipment_identifier": "794947717776", + "shipment_identifier": "fa3bb603-2687-4b38-ba18-3264208446c6", } -ParsedShipmentResponse = [] +ParsedShipmentResponse = [ + { + "carrier_id": "sapient", + "carrier_name": "sapient", + "docs": { + "label": "jVBERw0KGgoAAAANSUhEUgAA.....A4QAAAXcCAYAAAB6Q0CbAAAAAXNSR0IArs4" + }, + "label_type": "PDF", + "meta": { + "sapient_carrier": "RM", + "sapient_shipment_id": "fa3bb603-2687-4b38-ba18-3264208446c6", + "shipment_ids": ["fa3bb603-2687-4b38-ba18-3264208446c6"], + "tracking_numbers": ["TT123456785GB"], + }, + "shipment_identifier": "fa3bb603-2687-4b38-ba18-3264208446c6", + "tracking_number": "TT123456785GB", + }, + [], +] ParsedCancelShipmentResponse = ParsedCancelShipmentResponse = [ { @@ -131,129 +200,106 @@ def test_parse_cancel_shipment_response(self): ShipmentRequest = { - "ShipmentInformation": { - "ContentType": "NDX", - "Action": "Process", - "LabelFormat": "PDF", - "ServiceCode": "OLA", - "DescriptionOfGoods": "Clothing", - "ShipmentDate": "2024-06-17", - "CurrencyCode": "GBP", - "WeightUnitOfMeasure": "KG", - "DimensionsUnitOfMeasure": "MM", - "ContainerId": "South East", - "DeclaredWeight": 1.5, - "BusinessTransactionType": "01", - }, - "Shipper": { - "Address": { - "ContactName": "Jane Smith", - "CompanyName": "Company & Co.", - "ContactEmail": "email@server.com", - "ContactPhone": "607723456789", - "Line1": "Level 5", - "Line2": "Hashmoore House", - "Line3": "10 Sky Lane", - "Town": "Leatherhead", - "Postcode": "AA34 3AB", - "County": "Surrey", - "CountryCode": "GB", - }, - "ShippingAccountId": "1991b077-3934-4efc-b9cb-2a916436d3ae", - "ShippingLocationId": "f7f38476-3d11-4c8e-be61-20b158393401", - "Reference1": "OrderRef56", - "DepartmentNumber": "0123456789", - "EoriNumber": "GB213456789000", - "VatNumber": "GB213456789", - }, - "Destination": { - "Address": { - "ContactName": "John Smith", - "ContactEmail": "john.smith@example.com", - "CompanyName": "Company & Co.", - "ContactPhone": "07123456789", - "Line1": "10 Sky Road", - "Line2": "10 Sky Road", - "Line3": "", - "Town": "Sydney", - "Postcode": "2000", - "County": "NSW", - "CountryCode": "AU", - }, - "EoriNumber": "GB123456789000", - "VatNumber": "GB123456789", - }, "CarrierSpecifics": { - "ServiceLevel": "02", - "EbayVtn": "ebay1234abc", "ServiceEnhancements": [ {"Code": "CustomsEmail"}, {"Code": "CustomsPhone"}, {"Code": "Safeplace", "SafeplaceLocation": "Under the doormat"}, ], + "ServiceLevel": "02", + "EbayVtn": "ebay1234abc", }, "Customs": { - "ReasonForExport": "Sale Of Goods", - "Incoterms": "DDU", - "PreRegistrationNumber": "0123456789", - "PreRegistrationType": "GST", - "ShippingCharges": 55.82, - "OtherCharges": 32, - "QuotedLandedCost": 82.74, - "InvoiceNumber": "INV-12345", "InvoiceDate": "2024-06-17", - "ExportLicenceRequired": False, - "Airn": "231.002.999-00", + "InvoiceNumber": "INV-12345", + "ReasonForExport": "Sale of Goods", }, - "ReturnToSender": { + "Destination": { "Address": { - "ContactName": "Jane Smith", "CompanyName": "Company & Co.", - "ContactEmail": "email@server.com", - "ContactPhone": "07723456789", - "Line1": "Level 5", - "Line2": "Hashmoore House", - "Line3": "10 Sky Lane", - "Town": "Leatherhead", - "Postcode": "AA34 3AB", - "County": "Surrey", - "CountryCode": "GB", + "ContactEmail": "john.smith@example.com", + "ContactPhone": "07123456789", + "CountryCode": "AU", + "Line1": "10 Sky Road", + "Line2": "10 Sky Road", + "Postcode": "2000", + "Town": "Sydney", } }, - "Packages": [ - { - "PackageType": "Parcel", - "PackageOccurrence": 1, - "DeclaredWeight": 1.5, - "DeclaredValue": 98.99, - "Dimensions": {"Length": 40, "Width": 30, "Height": 20}, - } - ], "Items": [ { - "SkuCode": "SKU123", + "CountryOfOrigin": "CN", + "Description": "White Mens Large T-shirt", + "HSCode": "6109100010", "PackageOccurrence": 1, "Quantity": 1, - "Description": "White Mens Large T-shirt", + "SkuCode": "SKU123", "Value": 19.99, "Weight": 0.5, - "HSCode": "6109100010", - "CountryOfOrigin": "CN", }, { - "SkuCode": "SKU456", - "PackageOccurrence": 1, - "Quantity": 2, + "CountryOfOrigin": "CN", "Description": "Black Mens Large Jumper", + "HSCode": "6110113000", + "PackageOccurrence": 2, + "Quantity": 2, + "SkuCode": "SKU456", "Value": 32.99, "Weight": 0.3, - "HSCode": "6110113000", - "CountryOfOrigin": "CN", }, ], + "Packages": [ + { + "DeclaredWeight": 0.68, + "Dimensions": {"Height": 50.8, "Length": 101.6, "Width": 76.2}, + "PackageOccurrence": 1, + "PackageType": "Parcel", + } + ], + "ReturnToSender": { + "Address": { + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "07723456789", + "CountryCode": "GB", + "Line1": "Level 5", + "Line2": "Hashmoore House", + "Postcode": "AA34 3AB", + "Town": "Leatherhead", + } + }, + "ShipmentInformation": { + "Action": "Process", + "ContentType": "NDX", + "CurrencyCode": "GBP", + "DeclaredWeight": 0.68, + "DimensionsUnitOfMeasure": "CM", + "LabelFormat": "PDF", + "ServiceCode": "carrier_service", + "ShipmentDate": "2024-08-11", + "WeightUnitOfMeasure": "KG", + }, + "Shipper": { + "Address": { + "CompanyName": "Company & Co.", + "ContactEmail": "email@server.com", + "ContactPhone": "607723456789", + "CountryCode": "GB", + "Line1": "10 Sky Lane", + "Line2": "Hashmoore House", + "Postcode": "AA34 3AB", + "Town": "Leatherhead", + }, + "Reference1": "OrderRef56", + "VatNumber": "GB123456789", + }, +} + +ShipmentCancelRequest = { + "ShipmentIds": ["fa3bb603-2687-4b38-ba18-3264208446c6"], + "Status": "Cancel", } -ShipmentCancelRequest = {"ShipmentId": "fa3bb603-2687-4b38-ba18-3264208446c6"} ShipmentResponse = """{ "Labels": "jVBERw0KGgoAAAANSUhEUgAA.....A4QAAAXcCAYAAAB6Q0CbAAAAAXNSR0IArs4", diff --git a/modules/sdk/karrio/core/models.py b/modules/sdk/karrio/core/models.py index dfade53d79..58207629df 100644 --- a/modules/sdk/karrio/core/models.py +++ b/modules/sdk/karrio/core/models.py @@ -213,6 +213,7 @@ class PickupCancelRequest: address: Address = JStruct[Address] pickup_date: str = None reason: str = None + options: Dict = {} @attr.s(auto_attribs=True) From 4283901424459a3d096dafc8ac47ebd88a2db493 Mon Sep 17 00:00:00 2001 From: Daniel K Date: Sun, 11 Aug 2024 17:37:46 -0700 Subject: [PATCH 3/3] refactor: remove unecessary sapient database model --- .../karrio/mappers/sapient/settings.py | 3 ++- .../sapient/tests/sapient/test_shipment.py | 4 +++- modules/core/karrio/server/core/datatypes.py | 1 + .../providers/extension/models/sapient.py | 22 ------------------- requirements.build.txt | 1 + requirements.sdk.dev.txt | 1 + requirements.server.dev.txt | 1 + source.requirements.txt | 1 + 8 files changed, 10 insertions(+), 24 deletions(-) delete mode 100644 modules/core/karrio/server/providers/extension/models/sapient.py diff --git a/modules/connectors/sapient/karrio/mappers/sapient/settings.py b/modules/connectors/sapient/karrio/mappers/sapient/settings.py index cb2ab32cd1..449c34bdd8 100644 --- a/modules/connectors/sapient/karrio/mappers/sapient/settings.py +++ b/modules/connectors/sapient/karrio/mappers/sapient/settings.py @@ -22,11 +22,12 @@ class Settings(provider_utils.Settings): id: str = None test_mode: bool = False carrier_id: str = "sapient" - services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore account_country_code: str = "GB" metadata: dict = {} config: dict = {} + services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore + @property def shipping_services(self) -> typing.List[models.ServiceLevel]: if any(self.services or []): diff --git a/modules/connectors/sapient/tests/sapient/test_shipment.py b/modules/connectors/sapient/tests/sapient/test_shipment.py index 3fdef64253..059c1b1b3c 100644 --- a/modules/connectors/sapient/tests/sapient/test_shipment.py +++ b/modules/connectors/sapient/tests/sapient/test_shipment.py @@ -125,6 +125,7 @@ def test_parse_cancel_shipment_response(self): } ], "options": { + "shipment_date": "2024-08-11", "declared_value": 98.99, "sapient_customs_email": True, "sapient_customs_phone": True, @@ -133,7 +134,7 @@ def test_parse_cancel_shipment_response(self): }, "customs": { "content_type": "merchandise", - "incoterms": "DDU", + "incoterm": "DDU", "invoice": "INV-12345", "invoice_date": "2024-06-17", "options": { @@ -210,6 +211,7 @@ def test_parse_cancel_shipment_response(self): "EbayVtn": "ebay1234abc", }, "Customs": { + "Incoterms": "DDU", "InvoiceDate": "2024-06-17", "InvoiceNumber": "INV-12345", "ReasonForExport": "Sale of Goods", diff --git a/modules/core/karrio/server/core/datatypes.py b/modules/core/karrio/server/core/datatypes.py index 2d28c5be3b..d43b0ee2b1 100644 --- a/modules/core/karrio/server/core/datatypes.py +++ b/modules/core/karrio/server/core/datatypes.py @@ -150,6 +150,7 @@ class PickupCancelRequest(BasePickupCancelRequest): address: Address = jstruct.JStruct[Address] pickup_date: str = None reason: str = None + options: typing.Dict = {} @attr.s(auto_attribs=True) diff --git a/modules/core/karrio/server/providers/extension/models/sapient.py b/modules/core/karrio/server/providers/extension/models/sapient.py deleted file mode 100644 index 71894477ed..0000000000 --- a/modules/core/karrio/server/providers/extension/models/sapient.py +++ /dev/null @@ -1,22 +0,0 @@ -import django.db.models as models -import karrio.server.providers.models as providers - - -@providers.has_rate_sheet("sapient") -class SAPIENTSettings(providers.Carrier): - class Meta: - db_table = "sapient-settings" - verbose_name = "SAPIENT Settings" - verbose_name_plural = "SAPIENT Settings" - - client_id = models.CharField(max_length=100) - client_secret = models.CharField(max_length=100) - shipping_account_id = models.CharField(max_length=100) - services = models.ManyToManyField("ServiceLevel", blank=True) - - @property - def carrier_name(self) -> str: - return "sapient" - - -SETTINGS = SAPIENTSettings diff --git a/requirements.build.txt b/requirements.build.txt index 63d92bf0e4..390d0482c4 100644 --- a/requirements.build.txt +++ b/requirements.build.txt @@ -33,6 +33,7 @@ Django==4.2.15 -e ./modules/connectors/purolator -e ./modules/connectors/roadie -e ./modules/connectors/royalmail +-e ./modules/connectors/sapient -e ./modules/connectors/sendle -e ./modules/connectors/tge -e ./modules/connectors/tnt diff --git a/requirements.sdk.dev.txt b/requirements.sdk.dev.txt index a264a0c5ac..3c174f477f 100644 --- a/requirements.sdk.dev.txt +++ b/requirements.sdk.dev.txt @@ -29,6 +29,7 @@ -e ./modules/connectors/purolator -e ./modules/connectors/roadie -e ./modules/connectors/royalmail +-e ./modules/connectors/sapient -e ./modules/connectors/sendle -e ./modules/connectors/tge -e ./modules/connectors/tnt diff --git a/requirements.server.dev.txt b/requirements.server.dev.txt index 3ba8f0727a..f5c9a82f6e 100644 --- a/requirements.server.dev.txt +++ b/requirements.server.dev.txt @@ -33,6 +33,7 @@ Django==4.2.15 -e ./modules/connectors/purolator -e ./modules/connectors/roadie -e ./modules/connectors/royalmail +-e ./modules/connectors/sapient -e ./modules/connectors/sendle -e ./modules/connectors/tge -e ./modules/connectors/tnt diff --git a/source.requirements.txt b/source.requirements.txt index 3b8e2fbdc5..99d3d7134c 100644 --- a/source.requirements.txt +++ b/source.requirements.txt @@ -80,6 +80,7 @@ karrio.nationex @ file://${PWD}/modules/connectors/nationex karrio.purolator @ file://${PWD}/modules/connectors/purolator karrio.roadie @ file://${PWD}/modules/connectors/roadie karrio.royalmail @ file://${PWD}/modules/connectors/royalmail +karrio.sapient @ file://${PWD}/modules/connectors/sapient karrio.sendle @ file://${PWD}/modules/connectors/sendle karrio.server @ file://${PWD}/apps/api karrio.server.admin @ file://${PWD}/modules/admin