diff --git a/composer.json b/composer.json index 262880a..463458c 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "require": { "php": ">=7.4", "ext-mbstring": "*", + "ext-simplexml": "*", "behat/transliterator": "^1.3", "doctrine/event-manager": "^1.1", "doctrine/orm": "^2.7", diff --git a/src/Client/Bpost/Client.php b/src/Client/Bpost/Client.php new file mode 100644 index 0000000..1d1aed8 --- /dev/null +++ b/src/Client/Bpost/Client.php @@ -0,0 +1,92 @@ +httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->streamFactory = $streamFactory; + $this->baseUrl = $baseUrl; + } + + /** + * @throws ClientExceptionInterface + * @throws JsonException + */ + private function get(string $endpoint, array $params = []): array + { + return $this->sendRequest('GET', $endpoint, $params); + } + + /** + * @throws ClientExceptionInterface|JsonException + */ + private function sendRequest(string $method, string $endpoint, array $params = [], array $body = []): array + { + $url = $this->baseUrl . '/' . ltrim($endpoint, '/') . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986); + + $request = $this->requestFactory->createRequest($method, $url); + + if (count($body) > 0) { + $request = $request->withBody($this->streamFactory->createStream(json_encode($body))); + } + + $response = $this->httpClient->sendRequest($request); + + + if (200 !== $response->getStatusCode()) { + throw new RequestFailedException($request, $response, $response->getStatusCode()); + } + + $xml = simplexml_load_string($response->getBody()->getContents()); + + $data = []; + $poiList = $xml->PoiList->Poi ?? $xml->PickupPointList->Point ?? $xml->Poi; + + if ($poiList !== null) { + foreach ($poiList as $poi) { + $poiData = json_decode(json_encode($poi), true); + $data[] = $poiData['Record'] ?? $poiData; + } + } + + return $data; + } + + /** + * @throws ClientExceptionInterface|JsonException + */ + public function locate(ServicePointQueryInterface $servicePointQuery): iterable + { + return $this->get($servicePointQuery->getEndPoint(), $servicePointQuery->toArray()); + } +} diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php new file mode 100644 index 0000000..517de91 --- /dev/null +++ b/src/Client/ClientInterface.php @@ -0,0 +1,12 @@ +httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->streamFactory = $streamFactory; + $this->baseUrl = $baseUrl; + $this->apiKey = $apiKey; + } + + /** + * @throws ClientExceptionInterface + * @throws JsonException + */ + private function get(string $endpoint, array $params = []): array + { + return $this->sendRequest('GET', $endpoint, $params); + } + + /** + * @throws ClientExceptionInterface|JsonException + */ + private function sendRequest(string $method, string $endpoint, array $params = [], array $body = []): array + { + $url = $this->baseUrl . '/' . ltrim($endpoint, '/') . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986); + + $request = $this->requestFactory->createRequest($method, $url); + + if (count($body) > 0) { + $request = $request->withBody($this->streamFactory->createStream(json_encode($body))); + } + + $request = $request->withHeader('apikey', $this->apiKey); + $request = $request->withHeader('accept', 'application/json'); + + $response = $this->httpClient->sendRequest($request); + + if (200 !== $response->getStatusCode()) { + throw new RequestFailedException($request, $response, $response->getStatusCode()); + } + + $data = json_decode($response->getBody()->getContents(), true); + + if (!isset($data['GetLocationsResult']['ResponseLocation'])) + { + throw new \UnexpectedValueException( + "Expected field '['GetLocationsResult']['ResponseLocation']' to be set." + ); + } + + return $data['GetLocationsResult']['ResponseLocation']; + } + + /** + * @throws ClientExceptionInterface|JsonException + */ + public function locate(ServicePointQueryInterface $servicePointQuery): iterable + { + return $this->get($servicePointQuery->getEndPoint(), $servicePointQuery->toArray()); + } +} diff --git a/src/DependencyInjection/Compiler/RegisterFactoriesPass.php b/src/DependencyInjection/Compiler/RegisterFactoriesPass.php new file mode 100644 index 0000000..3849fb9 --- /dev/null +++ b/src/DependencyInjection/Compiler/RegisterFactoriesPass.php @@ -0,0 +1,74 @@ +register(sprintf(self::SERVICE_ID_FORMAT, $PROVIDER, self::PSR17_FACTORY_SUFFIX), Psr17Factory::class); + } + + $factoryId = sprintf(self::SERVICE_ID_FORMAT, $PROVIDER, self::PSR17_FACTORY_SUFFIX); + $requestFactoryAlias = sprintf(self::SERVICE_ID_FORMAT, $PROVIDER, self::REQUEST_FACTORY_SUFFIX); + $this->registerFactory( + $container, + $requestFactoryAlias, + $requestFactoryAlias, + $factoryId, + RequestFactoryInterface::class + ); + + $streamFactoryAlias = sprintf(self::SERVICE_ID_FORMAT, $PROVIDER, self::STREAM_FACTORY_SUFFIX); + $this->registerFactory($container, + $streamFactoryAlias, + $streamFactoryAlias, + $factoryId, + StreamFactoryInterface::class + ); + } + } + + private function registerFactory(ContainerBuilder $container, string $parameter, string $service, string $factoryId, string $factoryInterface): void + { + if ($container->hasParameter($parameter)) { + if (!$container->has($container->getParameter($parameter))) { + throw new ServiceNotFoundException($container->getParameter($parameter)); + } + + $container->setAlias($service, $container->getParameter($parameter)); + } elseif ($container->has($factoryInterface)) { + $container->setAlias($service, $factoryInterface); + } elseif ($container->has('nyholm.psr7.psr17_factory')) { + $container->setAlias($service, 'nyholm.psr7.psr17_factory'); + } elseif (class_exists(Psr17Factory::class)) { + $container->setAlias($service, $factoryId); + } + } +} diff --git a/src/DependencyInjection/Compiler/RegisterHttpClientPass.php b/src/DependencyInjection/Compiler/RegisterHttpClientPass.php new file mode 100644 index 0000000..f70bd01 --- /dev/null +++ b/src/DependencyInjection/Compiler/RegisterHttpClientPass.php @@ -0,0 +1,33 @@ + 'setono_bpost.http_client', + 'setono_postnl.http_client' => 'setono_postnl.http_client', + ]; + + public function process(ContainerBuilder $container): void + { + foreach (self::HTTP_CLIENT_PARAMETER_SERVICE_IDS as $PARAMETER => $SERVICE_ID) { + if ($container->hasParameter($PARAMETER)) { + if (!$container->has($container->getParameter($PARAMETER))) { + throw new ServiceNotFoundException($container->getParameter($PARAMETER)); + } + + $container->setAlias($SERVICE_ID, $container->getParameter($SERVICE_ID)); + } elseif ($container->has(BuzzClientInterface::class)) { + $container->setAlias($SERVICE_ID, BuzzClientInterface::class); + } + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2c33354..6ae03d6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -12,6 +12,7 @@ use Sylius\Bundle\ResourceBundle\Controller\ResourceController; use Sylius\Bundle\ResourceBundle\Form\Type\DefaultResourceType; use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; +use Sylius\Component\Core\Model\Address; use Sylius\Component\Resource\Factory\Factory; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; @@ -66,9 +67,76 @@ public function getConfigTreeBuilder(): TreeBuilder ->info('Whether to enable the PostNord provider') ->defaultValue(class_exists(SetonoPostNordBundle::class)) ->end() + ->arrayNode('bpost') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled') + ->example(true) + ->info('Whether to enable the Bpost provider') + ->defaultValue(false) + ->end() + ->scalarNode('base_url') + ->cannotBeEmpty() + ->defaultValue('https://pudo.bpost.be') + ->info('The base URL of the bpost API') + ->end() + ->scalarNode('partner_id') + ->cannotBeEmpty() + ->info('Bpost partner id required for making api calls') + ->end() + ->scalarNode('http_client') + ->cannotBeEmpty() + ->info('PSR18 HTTP client that is injected into the client service') + ->end() + ->scalarNode('request_factory') + ->cannotBeEmpty() + ->info('Is injected into the client service') + ->end() + ->scalarNode('stream_factory') + ->cannotBeEmpty() + ->info('Is injected into the client service') + ->end() + ->end() + ->end() + ->arrayNode('postnl') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled') + ->example(true) + ->info('Whether to enable the PostNL provider') + ->defaultValue(false) + ->end() + ->scalarNode('base_url') + ->cannotBeEmpty() + ->defaultValue('https://api-sandbox.postnl.nl') + ->info('The base URL of the PostNL API') + ->end() + ->scalarNode('address_class') + ->cannotBeEmpty() + ->defaultValue(Address::class) + ->info('The address class to use for querying all points based on encountered postcodes') + ->end() + ->scalarNode('api_key') + ->cannotBeEmpty() + ->info('PostNL api key required for making api calls') + ->end() + ->scalarNode('http_client') + ->cannotBeEmpty() + ->info('PSR18 HTTP client that is injected into the client service') + ->end() + ->scalarNode('request_factory') + ->cannotBeEmpty() + ->info('Is injected into the client service') + ->end() + ->scalarNode('stream_factory') + ->cannotBeEmpty() + ->info('Is injected into the client service') + ->end() + ->end() ->end() ->end() ->end() + ->end() ; $this->addResourcesSection($rootNode); diff --git a/src/DependencyInjection/SetonoSyliusPickupPointExtension.php b/src/DependencyInjection/SetonoSyliusPickupPointExtension.php index dfae998..9afb53f 100644 --- a/src/DependencyInjection/SetonoSyliusPickupPointExtension.php +++ b/src/DependencyInjection/SetonoSyliusPickupPointExtension.php @@ -71,5 +71,60 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('services/providers/post_nord.xml'); } + + if ($config['providers']['bpost'] && $config['providers']['bpost']['enabled'] === true) { + $bpostConfig = $config['providers']['bpost']; + $container->setParameter('setono_bpost.base_url', $bpostConfig['base_url']); + + if (isset($bpostConfig['http_client'])) { + $container->setParameter('setono_bpost.http_client', $bpostConfig['http_client']); + } + + if (isset($bpostConfig['request_factory'])) { + $container->setParameter('setono_bpost.request_factory', $bpostConfig['request_factory']); + } + + if (isset($bpostConfig['stream_factory'])) { + $container->setParameter('setono_bpost.stream_factory', $bpostConfig['stream_factory']); + } + + if (isset($bpostConfig['partner_id'])) { + $container->setParameter('setono_bpost.partner_id', $bpostConfig['partner_id']); + } + + $loader->load('services/providers/bpost.xml'); + $loader->load('services/clients/bpost.xml'); + $loader->load('services/transformers/bpost.xml'); + } + + if ($config['providers']['postnl'] && $config['providers']['postnl']['enabled'] === true) { + $postnlConfig = $config['providers']['postnl']; + + $container->setParameter('setono_postnl.base_url', $postnlConfig['base_url']); + + if (isset($postnlConfig['http_client'])) { + $container->setParameter('setono_postnl.http_client', $postnlConfig['http_client']); + } + + if (isset($postnlConfig['request_factory'])) { + $container->setParameter('setono_postnl.request_factory', $postnlConfig['request_factory']); + } + + if (isset($postnlConfig['stream_factory'])) { + $container->setParameter('setono_postnl.stream_factory', $postnlConfig['stream_factory']); + } + + if (isset($postnlConfig['api_key'])) { + $container->setParameter('setono_postnl.api_key', $postnlConfig['api_key']); + } + + if (isset($postnlConfig['address_class'])) { + $container->setParameter('setono_postnl.address_class', $postnlConfig['address_class']); + } + + $loader->load('services/providers/postnl.xml'); + $loader->load('services/clients/postnl.xml'); + $loader->load('services/transformers/postnl.xml'); + } } } diff --git a/src/Exception/RequestFailedException.php b/src/Exception/RequestFailedException.php new file mode 100644 index 0000000..a452d94 --- /dev/null +++ b/src/Exception/RequestFailedException.php @@ -0,0 +1,55 @@ +request = $request; + $this->response = $response; + $this->statusCode = $statusCode; + + $uri = self::resolveUri($this->request->getUri()); + + parent::__construct(sprintf('Request failed with status code %d. Request URI was: %s', $this->statusCode, $uri)); + } + + public function getRequest(): RequestInterface + { + return $this->request; + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + private static function resolveUri(UriInterface $uri): string + { + /** @var string $query */ + $query = preg_replace('/apikey=[^&]+/i', 'apikey=******', $uri->getQuery()); // this will mask the API key in logs etc + + return (string) $uri->withQuery($query); + } +} diff --git a/src/Factory/Bpost/ServicePointQueryFactory.php b/src/Factory/Bpost/ServicePointQueryFactory.php new file mode 100644 index 0000000..ba54e4b --- /dev/null +++ b/src/Factory/Bpost/ServicePointQueryFactory.php @@ -0,0 +1,73 @@ +partnerId = $partnerId; + } + + public function createServicePointQueryForOrder(OrderInterface $order): ServicePointQueryInterface + { + $servicePointQuery = new ServicePointQuery($this->partnerId); + $servicePointQuery->setFunction(ServicePointQueryInterface::FUNCTION_SEARCH); + $servicePointQuery->setLimit(20); + + $shippingAddress = $order->getShippingAddress(); + if (null === $shippingAddress) { + return $servicePointQuery; + } + + $street = $shippingAddress->getStreet(); + $postCode = $shippingAddress->getPostcode(); + $countryCode = $shippingAddress->getCountryCode(); + + if ($street !== null) { + $servicePointQuery->setStreet($street); + } + + if ($countryCode !== null) { + $servicePointQuery->setCountry($countryCode); + } + + if ($postCode !== null) { + $servicePointQuery->setZone($postCode); + } + + return $servicePointQuery; + } + + public function createServicePointQueryForPickupPoint(PickupPointCode $pickupPointCode): ServicePointQueryInterface + { + $servicePointQuery = new ServicePointQuery($this->partnerId); + $servicePointQuery->setFunction(ServicePointQueryInterface::FUNCTION_INFO); + $servicePointQuery->setId($pickupPointCode->getIdPart()); + $servicePointQuery->setCountry($pickupPointCode->getCountryPart()); + + return $servicePointQuery; + } + + public function createServicePointQueryForAllPickupPoints(string $countryCode, ?string $postalCode = null): ServicePointQueryInterface + { + $servicePointQuery = new ServicePointQuery($this->partnerId); + $servicePointQuery->setFunction(ServicePointQueryInterface::FUNCTION_GET_ALL_SERVICE_POINTS); + $servicePointQuery->setAccount($this->partnerId); + $servicePointQuery->setCountry($countryCode); + + if ($postalCode !== null) { + $servicePointQuery->setZone($postalCode); + } + + return $servicePointQuery; + } +} diff --git a/src/Factory/PostNL/ServicePointQueryFactory.php b/src/Factory/PostNL/ServicePointQueryFactory.php new file mode 100644 index 0000000..70b7058 --- /dev/null +++ b/src/Factory/PostNL/ServicePointQueryFactory.php @@ -0,0 +1,67 @@ +getShippingAddress(); + if (null === $shippingAddress) { + return $servicePointQuery; + } + + $street = $shippingAddress->getStreet(); + $postCode = $shippingAddress->getPostcode(); + $countryCode = $shippingAddress->getCountryCode(); + $city = $shippingAddress->getCity(); + + if ($street !== null) { + $servicePointQuery->setStreet($street); + } + + if ($countryCode !== null) { + $servicePointQuery->setCountryCode($countryCode); + } + + if ($postCode !== null) { + $servicePointQuery->setPostalCode($postCode); + } + + if ($city !== null) { + $servicePointQuery->setCity($city); + } + + return $servicePointQuery; + } + + public function createServicePointQueryForPickupPoint(PickupPointCode $pickupPointCode): ServicePointQueryInterface + { + return new ServicePointLookup( + $pickupPointCode->getIdPart(), + ServicePointLookupInterface::RETAIL_NETWORK_IDS[$pickupPointCode->getCountryPart()] + ); + } + + public function createServicePointQueryForAllPickupPoints(string $countryCode, ?string $postalCode = null): ServicePointQueryInterface + { + $servicePointQuery = new ServicePointQuery(); + $servicePointQuery->setCountryCode($countryCode); + + if ($postalCode !== null) { + $servicePointQuery->setPostalCode($postalCode); + } + + return $servicePointQuery; + } +} diff --git a/src/Factory/ServicePointQueryFactoryInterface.php b/src/Factory/ServicePointQueryFactoryInterface.php new file mode 100644 index 0000000..0fcd4ba --- /dev/null +++ b/src/Factory/ServicePointQueryFactoryInterface.php @@ -0,0 +1,16 @@ +checkData = 1; + $this->checkOpen = 1; + $this->checkList = 1; + $this->info = 1; + $this->limit = 20; + + $this->partner = $partner; + } + + public function getId(): string + { + return $this->id; + } + + public function setId(string $id): void + { + $this->id = $id; + } + + public function getFunction(): string + { + return $this->function; + } + + public function setFunction(string $function): void + { + if (!in_array($function, self::FUNCTIONS, true)) { + throw new \LogicException( + sprintf( + 'Function: %s is not known, supported functions are: %s', + $function, + implode(', ', self::FUNCTIONS) + ) + ); + } + $this->function = $function; + } + + public function getLanguage(): string + { + return $this->language; + } + + public function setLanguage(string $language): void + { + $this->language = $language; + } + + public function getStreet(): string + { + return $this->street; + } + + public function setStreet(string $street): void + { + $this->street = $street; + } + + public function getNumber(): string + { + return $this->number; + } + + public function setNumber(string $number): void + { + $this->number = $number; + } + + public function getZone(): string + { + return $this->zone; + } + + public function setZone(string $zone): void + { + $this->zone = $zone; + } + + public function getType(): int + { + return $this->type; + } + + public function setType(int $type): void + { + if (!in_array($type, self::TYPES, true)) { + throw new \LogicException( + sprintf( + 'Type: %s is not known, supported types are: %s', + $type, + implode(', ', self::TYPES) + ) + ); + } + $this->type = $type; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function setLimit(int $limit): void + { + $this->limit = $limit; + } + + public function getPartner(): string + { + return $this->partner; + } + + public function getAccount(): string + { + return $this->account; + } + + public function setAccount(string $account): void + { + $this->account = $account; + } + + public function getCountry(): string + { + return $this->country; + } + + public function setCountry(string $country): void + { + $this->country = $country; + } + + public function getEndPoint(): string { + return self::ENDPOINT; + } + + public function getCheckData(): int + { + return (int) $this->checkData; + } + + public function setCheckData(bool $checkData): void + { + $this->checkData = $checkData; + } + + public function getCheckList(): int + { + return (int) $this->checkList; + } + + public function setCheckList(bool $checkList): void + { + $this->checkList = $checkList; + } + + public function getCheckOpen(): int + { + return (int) $this->checkOpen; + } + + public function setCheckOpen(bool $checkOpen): void + { + $this->checkOpen = $checkOpen; + } + + public function getInfo(): int + { + return (int) $this->info; + } + + public function setInfo(bool $info): void + { + $this->info = $info; + } + + public function toArray(): array + { + $arrayValue = []; + foreach(get_object_vars($this) as $key => $value) { + if (is_bool($value)) { + $value = (int) $value; + } + $arrayValue[ucfirst($key)] = $value; + } + return $arrayValue; + } +} diff --git a/src/Model/Query/Bpost/ServicePointQueryInterface.php b/src/Model/Query/Bpost/ServicePointQueryInterface.php new file mode 100644 index 0000000..31d0fa1 --- /dev/null +++ b/src/Model/Query/Bpost/ServicePointQueryInterface.php @@ -0,0 +1,32 @@ + 1, + self::TYPE_POST_POINT => 2, + self::TYPE_PACK_STATION => 4, + self::TYPE_SHOP => 8, + self::TYPE_KARIBOO => 16, + ]; +} diff --git a/src/Model/Query/PostNL/ServicePointLookup.php b/src/Model/Query/PostNL/ServicePointLookup.php new file mode 100644 index 0000000..a9b0b59 --- /dev/null +++ b/src/Model/Query/PostNL/ServicePointLookup.php @@ -0,0 +1,31 @@ +locationCode = $locationCode; + $this->retailNetworkId = $retailNetworkId; + } + + public function getEndPoint(): string + { + return self::ENDPOINT; + } + + public function toArray(): array + { + return [ + 'RetailNetorkID' => $this->retailNetworkId, + 'LocationCode' => $this->locationCode, + ]; + } +} diff --git a/src/Model/Query/PostNL/ServicePointLookupInterface.php b/src/Model/Query/PostNL/ServicePointLookupInterface.php new file mode 100644 index 0000000..e3a1dc6 --- /dev/null +++ b/src/Model/Query/PostNL/ServicePointLookupInterface.php @@ -0,0 +1,16 @@ + self::RETAIL_NETWORK_ID_BE, + 'NL' => self::RETAIL_NETWORK_ID_NL, + ]; + + public const RETAIL_NETWORK_ID_BE = 'PNPBE-01'; + public const RETAIL_NETWORK_ID_NL = 'PNPNL-01'; +} diff --git a/src/Model/Query/PostNL/ServicePointQuery.php b/src/Model/Query/PostNL/ServicePointQuery.php new file mode 100644 index 0000000..2559ddf --- /dev/null +++ b/src/Model/Query/PostNL/ServicePointQuery.php @@ -0,0 +1,134 @@ +id; + } + + public function setId(string $id): void + { + $this->id = $id; + } + + public function getStreet(): string + { + return $this->street; + } + + public function setStreet(string $street): void + { + $this->street = $street; + } + + public function getCountryCode(): string + { + return $this->countryCode; + } + + public function setCountryCode(string $countryCode): void + { + $this->countryCode = $countryCode; + } + + public function getPostalCode(): string + { + return $this->postalCode; + } + + public function setPostalCode(string $postalCode): void + { + $this->postalCode = $postalCode; + } + + public function getCity(): string + { + return $this->city; + } + + public function setCity(string $city): void + { + $this->city = $city; + } + + public function getHouseNumber(): string + { + return $this->houseNumber; + } + + public function setHouseNumber(string $houseNumber): void + { + $this->houseNumber = $houseNumber; + } + + public function getDeliveryDate(): \DateTime + { + return $this->deliveryDate; + } + + public function setDeliveryDate(\DateTime $deliveryDate): void + { + $this->deliveryDate = $deliveryDate; + } + + public function getOpeningTime(): \DateTime + { + return $this->openingTime; + } + + public function setOpeningTime(\DateTime $openingTime): void + { + $this->openingTime = $openingTime; + } + + public function getDeliveryOptions(): array + { + return $this->deliveryOptions; + } + + public function setDeliveryOptions(array $deliveryOptions): void + { + $this->deliveryOptions = $deliveryOptions; + } + + public function getEndPoint(): string { + return self::ENDPOINT; + } + + public function toArray(): array + { + $arrayValue = []; + foreach(get_object_vars($this) as $key => $value) { + if (is_bool($value)) { + $value = (int) $value; + } + $arrayValue[ucfirst($key)] = $value; + } + return $arrayValue; + } +} diff --git a/src/Model/Query/ServicePointQueryInterface.php b/src/Model/Query/ServicePointQueryInterface.php new file mode 100644 index 0000000..8e1ce5f --- /dev/null +++ b/src/Model/Query/ServicePointQueryInterface.php @@ -0,0 +1,10 @@ +client = $client; + $this->countryCodes = $countryCodes; + $this->servicePointQueryFactory = $servicePointQueryFactory; + $this->partnerId = $partnerId; + $this->pickupPointTransformer = $pickupPointTransformer; + } + + public function findPickupPoints(OrderInterface $order): iterable + { + $shippingAddress = $order->getShippingAddress(); + if (null === $shippingAddress) { + return []; + } + + $countryCode = $shippingAddress->getCountryCode(); + if (null === $countryCode) { + return []; + } + + $servicePointQuery = $this->getServicePointQueryFactory()->createServicePointQueryForOrder($order); + $servicePoints = $this->client->locate($servicePointQuery); + foreach ($servicePoints as $item) { + $item['country'] = $countryCode; + yield $this->transform($item); + } + } + + public function findPickupPoint(PickupPointCode $code): ?PickupPointInterface + { + $servicePoints = []; + try { + $servicePointQuery = $this->getServicePointQueryFactory()->createServicePointQueryForPickupPoint($code); + foreach (ServicePointQueryInterface::TYPES as $type) { + $servicePointQuery->setType($type); + $servicePoints = $this->client->locate($servicePointQuery); + if (count($servicePoints) > 0) { + break; + } + } + } catch (NetworkExceptionInterface $e) { + throw new TimeoutException($e); + } + + if (count($servicePoints) < 1) { + return null; + } + + $servicePoint = $servicePoints[0]; + $servicePoint['country'] = $code->getCountryPart(); + return $this->transform($servicePoint); + } + + public function findAllPickupPoints(): iterable + { + try { + foreach ($this->countryCodes as $countryCode) { + $servicePointQuery = $this->getServicePointQueryFactory()->createServicePointQueryForAllPickupPoints($countryCode); + + $servicePointQuery->setCountry($countryCode); + $servicePoints = $this->client->locate($servicePointQuery); + foreach ($servicePoints as $item) { + $item['country'] = $countryCode; + yield $this->transform($item); + } + } + } catch (NetworkExceptionInterface $e) { + throw new TimeoutException($e); + } + } + + public function getCode(): string + { + return self::CODE; + } + + public function getName(): string + { + return self::NAME; + } + + private function getServicePointQueryFactory(): ServicePointQueryFactoryInterface + { + if ($this->servicePointQueryFactory === null) { + $this->servicePointQueryFactory = new ServicePointQueryFactory($this->partnerId); + } + return $this->servicePointQueryFactory; + } + + private function transform(array $servicePoint): PickupPointInterface + { + return $this->pickupPointTransformer->transform($servicePoint, $this->getCode()); + } +} diff --git a/src/Provider/PostNLProvider.php b/src/Provider/PostNLProvider.php new file mode 100644 index 0000000..8989490 --- /dev/null +++ b/src/Provider/PostNLProvider.php @@ -0,0 +1,154 @@ +client = $client; + $this->countryCodes = $countryCodes; + $this->servicePointQueryFactory = $servicePointQueryFactory; + $this->pickupPointTransformer = $pickupPointTransformer; + $this->managerRegistry = $managerRegistry; + $this->addressClassString = $addressClassString; + } + + public function findPickupPoints(OrderInterface $order): iterable + { + $shippingAddress = $order->getShippingAddress(); + if (null === $shippingAddress) { + return []; + } + + $countryCode = $shippingAddress->getCountryCode(); + if (null === $countryCode) { + return []; + } + + $servicePointQuery = $this->getServicePointQueryFactory()->createServicePointQueryForOrder($order); + $servicePoints = $this->client->locate($servicePointQuery); + foreach ($servicePoints as $item) { + $item['country'] = $countryCode; + yield $this->transform($item); + } + } + + public function findPickupPoint(PickupPointCode $code): ?PickupPointInterface + { + try { + $servicePointQuery = $this->getServicePointQueryFactory()->createServicePointQueryForPickupPoint($code); + $servicePoint = $this->client->locate($servicePointQuery); + } catch (NetworkExceptionInterface $e) { + throw new TimeoutException($e); + } + + $servicePoints = []; + foreach ($servicePoint as $index => $point) { + $servicePoints[$index] = $point; + } + + if (\count($servicePoints) < 1) { + return null; + } + + return $this->transform($servicePoints); + } + + /** + * As there is currently no good alternative to query all pickup points for a given country, this will be provided + * as an alternative. + */ + public function findAllPickupPoints(): iterable + { + if (!class_exists($this->addressClassString)) { + throw new \InvalidArgumentException(sprintf("Class '%s' does not exist", $this->addressClassString)); + } + + $manager = $this->managerRegistry->getManagerForClass($this->addressClassString); + Assert::notNull($manager); + + /** @var EntityRepository $repository */ + $repository = $manager->getRepository($this->addressClassString); + try { + foreach ($this->countryCodes as $countryCode) { + $qb = $repository->createQueryBuilder('sa'); + $postalCodes = $qb->distinct()->select('sa.postcode') + ->where('sa.countryCode = :countryCode') + ->setParameter('countryCode', $countryCode) + ->getQuery()->getResult(); + + $postalCodes = array_map(static function (array $code) { + return $code['postcode']; + }, $postalCodes); + + foreach ($postalCodes as $postalCode) { + $servicePointQuery = $this->getServicePointQueryFactory() + ->createServicePointQueryForAllPickupPoints($countryCode, $postalCode); + $servicePoints = $this->client->locate($servicePointQuery); + foreach ($servicePoints as $item) { + $item['country'] = $countryCode; + yield $this->transform($item); + } + } + } + } catch (NetworkExceptionInterface $e) { + throw new TimeoutException($e); + } + } + public function getCode(): string + { + return self::CODE; + } + + public function getName(): string + { + return self::NAME; + } + + private function getServicePointQueryFactory(): ServicePointQueryFactoryInterface + { + if ($this->servicePointQueryFactory === null) { + $this->servicePointQueryFactory = new ServicePointQueryFactory(); + } + return $this->servicePointQueryFactory; + } + + private function transform(array $servicePoint): PickupPointInterface + { + return $this->pickupPointTransformer->transform($servicePoint, $this->getCode()); + } +} diff --git a/src/Resources/config/app/fixtures.yaml b/src/Resources/config/app/fixtures.yaml index 96f6670..5cc22ce 100644 --- a/src/Resources/config/app/fixtures.yaml +++ b/src/Resources/config/app/fixtures.yaml @@ -21,6 +21,8 @@ sylius_fixtures: - 'ES' - 'CN' - 'UK' + - 'NL' + - 'BE' zones: WORLD: name: 'World' @@ -39,6 +41,8 @@ sylius_fixtures: - 'ES' - 'CN' - 'UK' + - 'NL' + - 'BE' DAO_PP_COUNTRIES: name: 'Denmark' countries: @@ -61,6 +65,16 @@ sylius_fixtures: - 'PT' - 'ES' - 'UK' + BPOST_PP_COUNTRIES: + name: 'Bpost countries' + countries: + - 'NL' + - 'BE' + POSTNL_PP_COUNTRIES: + name: 'PostNL countries' + countries: + - 'NL' + - 'BE' setono_sylius_pickup_point_shipping_method: options: @@ -103,6 +117,22 @@ sylius_fixtures: zone: "WORLD" channels: - "FASHION_WEB" + postnl_pickup_point: + code: "postnl_pickup_point" + name: "PostNL with pickup points" + enabled: true + zone: "POSTNL_PP_COUNTRIES" + pickup_point_provider: postnl + channels: + - "FASHION_WEB" + bpost_pickup_point: + code: "bpost_pickup_point" + name: "Bpost with pickup points" + enabled: true + zone: "BPOST_PP_COUNTRIES" + pickup_point_provider: bpost + channels: + - "FASHION_WEB" faker: code: "faker" name: "Fake delivery" diff --git a/src/Resources/config/services/clients/bpost.xml b/src/Resources/config/services/clients/bpost.xml new file mode 100644 index 0000000..3d1437c --- /dev/null +++ b/src/Resources/config/services/clients/bpost.xml @@ -0,0 +1,13 @@ + + + + + + + + + + %setono_bpost.base_url% + + + diff --git a/src/Resources/config/services/clients/postnl.xml b/src/Resources/config/services/clients/postnl.xml new file mode 100644 index 0000000..3bc83e4 --- /dev/null +++ b/src/Resources/config/services/clients/postnl.xml @@ -0,0 +1,14 @@ + + + + + + + + + + %setono_postnl.api_key% + %setono_postnl.base_url% + + + diff --git a/src/Resources/config/services/providers/bpost.xml b/src/Resources/config/services/providers/bpost.xml new file mode 100644 index 0000000..a0d0019 --- /dev/null +++ b/src/Resources/config/services/providers/bpost.xml @@ -0,0 +1,15 @@ + + + + + + + + %setono_bpost.partner_id% + + + + diff --git a/src/Resources/config/services/providers/postnl.xml b/src/Resources/config/services/providers/postnl.xml new file mode 100644 index 0000000..485ca30 --- /dev/null +++ b/src/Resources/config/services/providers/postnl.xml @@ -0,0 +1,16 @@ + + + + + + + + + %setono_postnl.address_class% + + + + diff --git a/src/Resources/config/services/transformers/bpost.xml b/src/Resources/config/services/transformers/bpost.xml new file mode 100644 index 0000000..e85098c --- /dev/null +++ b/src/Resources/config/services/transformers/bpost.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Resources/config/services/transformers/postnl.xml b/src/Resources/config/services/transformers/postnl.xml new file mode 100644 index 0000000..d20cd10 --- /dev/null +++ b/src/Resources/config/services/transformers/postnl.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Resources/translations/messages.da.yml b/src/Resources/translations/messages.da.yml index 099cc90..370741f 100644 --- a/src/Resources/translations/messages.da.yml +++ b/src/Resources/translations/messages.da.yml @@ -13,3 +13,5 @@ setono_sylius_pickup_point: dao: DAO gls: GLS post_nord: PostNord + postnl: Post NL + bpost: Bpost diff --git a/src/Resources/translations/messages.en.yml b/src/Resources/translations/messages.en.yml index 2d26668..5fb0d3c 100644 --- a/src/Resources/translations/messages.en.yml +++ b/src/Resources/translations/messages.en.yml @@ -13,3 +13,5 @@ setono_sylius_pickup_point: dao: DAO gls: GLS post_nord: PostNord + postnl: Post NL + bpost: Bpost diff --git a/src/SetonoSyliusPickupPointPlugin.php b/src/SetonoSyliusPickupPointPlugin.php index 1844b60..85a3912 100644 --- a/src/SetonoSyliusPickupPointPlugin.php +++ b/src/SetonoSyliusPickupPointPlugin.php @@ -5,6 +5,8 @@ namespace Setono\SyliusPickupPointPlugin; use Setono\SyliusPickupPointPlugin\DependencyInjection\Compiler\RegisterProvidersPass; +use Setono\SyliusPickupPointPlugin\DependencyInjection\Compiler\RegisterHttpClientPass; +use Setono\SyliusPickupPointPlugin\DependencyInjection\Compiler\RegisterFactoriesPass; use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; use Sylius\Bundle\ResourceBundle\AbstractResourceBundle; use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; @@ -19,6 +21,8 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass(new RegisterProvidersPass()); + $container->addCompilerPass(new RegisterHttpClientPass()); + $container->addCompilerPass(new RegisterFactoriesPass()); } public function getSupportedDrivers(): array diff --git a/src/Transformer/Bpost/PickupPointTransformer.php b/src/Transformer/Bpost/PickupPointTransformer.php new file mode 100644 index 0000000..41936ef --- /dev/null +++ b/src/Transformer/Bpost/PickupPointTransformer.php @@ -0,0 +1,63 @@ +pickupPointFactory = $pickupPointFactory; + } + + public function transform(array $servicePoint, string $providerCode): PickupPointInterface + { + $servicePointSanitized = $this->arrayChangeKeyCaseRecursive($servicePoint); + $id = new PickupPointCode( + $servicePointSanitized['id'], + $providerCode, + $servicePointSanitized['country'] ?? $servicePointSanitized['city'] + ); + + $address = trim( + sprintf( + '%s %s', + $servicePointSanitized['street'] ?? '', + $servicePointSanitized['number'] ?? $servicePointSanitized['nr'] ?? '', + ) + ); + $latitude = $servicePointSanitized['latitude'] ? (float) $servicePointSanitized['latitude'] : null; + $longitude = $servicePointSanitized['longitude'] ? (float) $servicePointSanitized['longitude'] : null; + + /** @var PickupPointInterface|object $pickupPoint */ + $pickupPoint = $this->pickupPointFactory->createNew(); + Assert::isInstanceOf($pickupPoint, PickupPointInterface::class); + $pickupPoint->setCode($id); + $pickupPoint->setName($servicePointSanitized['name'] ?? $servicePointSanitized['office']); + $pickupPoint->setAddress($address); + $pickupPoint->setZipCode((string) $servicePointSanitized['zip']); + $pickupPoint->setCity($servicePointSanitized['city']); + $pickupPoint->setCountry($servicePointSanitized['country']); + $pickupPoint->setLatitude($latitude); + $pickupPoint->setLongitude($longitude); + + return $pickupPoint; + } + + private function arrayChangeKeyCaseRecursive($arr, $case = CASE_LOWER): array + { + return array_map(function($item) use($case) { + if(is_array($item)) { + $item = $this->arrayChangeKeyCaseRecursive($item, $case); + } + return $item; + },array_change_key_case($arr, $case)); + } +} diff --git a/src/Transformer/PickupPointTransformerInterface.php b/src/Transformer/PickupPointTransformerInterface.php new file mode 100644 index 0000000..92bb1f1 --- /dev/null +++ b/src/Transformer/PickupPointTransformerInterface.php @@ -0,0 +1,10 @@ +pickupPointFactory = $pickupPointFactory; + } + + public function transform(array $servicePoint, string $providerCode): PickupPointInterface + { + $servicePointSanitized = $this->arrayChangeKeyCaseRecursive($servicePoint); + $servicePointAddress = $servicePointSanitized['address']; + + $id = new PickupPointCode( + $servicePointSanitized['locationcode'], + $providerCode, + $servicePointAddress['countrycode'] + ); + + $address = trim( + sprintf( + '%s %s', + $servicePointAddress['street'] ?? '', + $servicePointAddress['housenr'] ?? '', + ) + ); + + $latitude = $servicePointSanitized['latitude'] ? (float) $servicePointSanitized['latitude'] : null; + $longitude = $servicePointSanitized['longitude'] ? (float) $servicePointSanitized['longitude'] : null; + + /** @var PickupPointInterface|object $pickupPoint */ + $pickupPoint = $this->pickupPointFactory->createNew(); + Assert::isInstanceOf($pickupPoint, PickupPointInterface::class); + + $pickupPoint->setCode($id); + $pickupPoint->setName($servicePointSanitized['name']); + $pickupPoint->setAddress($address); + $pickupPoint->setZipCode((string) $servicePointAddress['zipcode']); + $pickupPoint->setCity($servicePointAddress['city']); + $pickupPoint->setCountry($servicePointAddress['countrycode']); + $pickupPoint->setLatitude($latitude); + $pickupPoint->setLongitude($longitude); + + return $pickupPoint; + } + + private function arrayChangeKeyCaseRecursive($arr, $case = CASE_LOWER): array + { + return array_map(function($item) use($case) { + if(is_array($item)) { + $item = $this->arrayChangeKeyCaseRecursive($item, $case); + } + return $item; + },array_change_key_case($arr, $case)); + } +} diff --git a/tests/Application/.env b/tests/Application/.env index 85229f7..d76d139 100644 --- a/tests/Application/.env +++ b/tests/Application/.env @@ -30,3 +30,11 @@ DAO_PASSWORD= ###> setono/post-nord-bundle ### POST_NORD_API_KEY= ###< setono/post-nord-bundle ### + +###> setono/postnl-bundle ### +POSTNL_API_KEY= +###< setono/postnl-bundle ### + +###> setono/bpost-bundle ### +BPOST_PARTNER_ID= +###< setono/bpost-bundle ### diff --git a/tests/Application/config/packages/setono_sylius_pickup_point.yaml b/tests/Application/config/packages/setono_sylius_pickup_point.yaml index 0265651..81bbe7d 100644 --- a/tests/Application/config/packages/setono_sylius_pickup_point.yaml +++ b/tests/Application/config/packages/setono_sylius_pickup_point.yaml @@ -18,3 +18,9 @@ setono_sylius_pickup_point: gls: true post_nord: true dao: true + bpost: + enabled: true + partner_id: '%env(BPOST_PARTNER_ID)%' + postnl: + enabled: true + api_key: '%env(POSTNL_API_KEY)%'