From c434664fd0ca64a4f7fc1624aed4d25bc52fa5a3 Mon Sep 17 00:00:00 2001 From: Harald Welte Date: Sun, 26 Jun 2022 20:09:22 +0200 Subject: [PATCH] first working version making API calls --- SmcShipcloudLive/composer.json | 16 +- .../Cart/Delivery/SmcDeliveryProcessor.php | 267 ++++++++++++++++-- .../Error/UnableToGetShippingQuoteError.php | 42 +++ .../Resources/app/administration/en-GB.json | 5 + .../src/Resources/config/services.xml | 4 + .../vendor/comyo-media/shipcloud-php | 1 + 6 files changed, 312 insertions(+), 23 deletions(-) create mode 100644 SmcShipcloudLive/src/Core/Checkout/Cart/Error/UnableToGetShippingQuoteError.php create mode 100644 SmcShipcloudLive/src/Resources/app/administration/en-GB.json create mode 160000 SmcShipcloudLive/vendor/comyo-media/shipcloud-php diff --git a/SmcShipcloudLive/composer.json b/SmcShipcloudLive/composer.json index 730e33b..f0af083 100644 --- a/SmcShipcloudLive/composer.json +++ b/SmcShipcloudLive/composer.json @@ -1,21 +1,31 @@ { "name": "sysmocom/shopware6-shipcloud-live", "description": "shipcloud live quote pluging", - "version": "0.0.1", + "version": "0.0.2", "type": "shopware-platform-plugin", "license": "AGPL-3.0-or-later", "authors": [ { "name": "Harald Welte", + "email": "hwelte@sysmocom.de", "role": "main developer" } ], + "minimum-stability": "dev", + "repositories": [ + { + "url": "https://github.com/comyo-media/shipcloud-php", + "type": "git" + } + ], "require": { - "shopware/core": "^6." + "shopware/core": "^6.", + "comyo-media/shipcloud-php": "9999999-dev" }, "autoload": { "psr-4": { - "SmcShipcloudLive\\": "src/" + "SmcShipcloudLive\\": "src/", + "ComyoMedia\\Shipcloud\\": "vendor/comyo-media/shipcloud-php/src/" } }, "extra": { diff --git a/SmcShipcloudLive/src/Core/Checkout/Cart/Delivery/SmcDeliveryProcessor.php b/SmcShipcloudLive/src/Core/Checkout/Cart/Delivery/SmcDeliveryProcessor.php index 17f1ec2..6732246 100644 --- a/SmcShipcloudLive/src/Core/Checkout/Cart/Delivery/SmcDeliveryProcessor.php +++ b/SmcShipcloudLive/src/Core/Checkout/Cart/Delivery/SmcDeliveryProcessor.php @@ -2,34 +2,73 @@ namespace SmcShipcloudLive\Core\Checkout\Cart\Delivery; +use SmcShipcloudLive\Core\Checkout\Cart\Error\UnableToGetShippingQuoteError; + +use Psr\Log\LoggerInterface; use Shopware\Core\Checkout\Cart\Cart; use Shopware\Core\Checkout\Cart\CartBehavior; use Shopware\Core\Checkout\Cart\CartDataCollectorInterface; use Shopware\Core\Checkout\Cart\CartProcessorInterface; -use Shopware\Core\Checkout\Cart\LineItem\CartDataCollection; -use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; -use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\Core\Checkout\Shipping\Cart\Error\ShippingMethodBlockedError; - use Shopware\Core\Checkout\Cart\Delivery\DeliveryBuilder; use Shopware\Core\Checkout\Cart\Delivery\DeliveryCalculator; -use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface; +use Shopware\Core\Checkout\Cart\Delivery\Struct\Delivery; +use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryCollection; +use Shopware\Core\Checkout\Cart\LineItem\CartDataCollection; +use Shopware\Core\Checkout\Cart\LineItem\LineItemCollection; +use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; +use Shopware\Core\Checkout\Cart\Price\Struct\CartPrice; +use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition; +use Shopware\Core\Checkout\Cart\Price\QuantityPriceCalculator; +use Shopware\Core\Checkout\Cart\Tax\PercentageTaxRuleBuilder; use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection; use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\Core\System\SystemConfig\SystemConfigService; +use Shopware\Core\Checkout\Shipping\Cart\Error\ShippingMethodBlockedError; +use Shopware\Core\Checkout\Shipping\ShippingMethodEntity; + +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface; +use Shopware\Core\Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity; + +use ComyoMedia\Shipcloud\Shipcloud; class SmcDeliveryProcessor implements CartProcessorInterface, CartDataCollectorInterface { - protected $builder; - protected $deliveryCalculator; - protected $shippingMethodRepository; + public const MANUAL_SHIPPING_COSTS = 'manualShippingCosts'; + public const SKIP_DELIVERY_PRICE_RECALCULATION = 'skipDeliveryPriceRecalculation'; + public const SKIP_DELIVERY_TAX_RECALCULATION = 'skipDeliveryTaxRecalculation'; + + protected DeliveryBuilder $builder; + protected DeliveryCalculator $deliveryCalculator; + protected EntityRepositoryInterface $shippingMethodRepository; + protected QuantityPriceCalculator $quantityPriceCalculator; + protected PercentageTaxRuleBuilder $percentageTaxRuleBuilder; + protected SystemConfigService $systemConfigService; + protected LoggerInterface $logger; public function __construct(DeliveryBuilder $builder, DeliveryCalculator $deliveryCalculator, - EntityRepositoryInterface $shippingMethodRepository) + EntityRepositoryInterface $shippingMethodRepository, + QuantityPriceCalculator $quantityPriceCalculator, + PercentageTaxRuleBuilder $percentageTaxRuleBuilder, + SystemConfigService $systemConfigService, + LoggerInterface $logger) { $this->builder = $builder; $this->deliveryCalculator = $deliveryCalculator; $this->shippingMethodRepository = $shippingMethodRepository; + $this->quantityPriceCalculator = $quantityPriceCalculator; + $this->percentageTaxRuleBuilder = $percentageTaxRuleBuilder; + $this->systemConfigService = $systemConfigService; + $this->logger = $logger; + + /* obtain API key from SystemConfig */ + if ($this->systemConfigService->get('SmcShipcloudLive.config.useSandbox')) { + $sc_api_key = $this->systemConfigService->get('SmcShipcloudLive.config.sandboxApiKey'); + } else { + $sc_api_key = $this->systemConfigService->get('SmcShipcloudLive.config.apiKey'); + } + $this->shipcloud = new Shipcloud($sc_api_key); } public static function buildKey(string $shippingMethodId): string @@ -39,13 +78,15 @@ class SmcDeliveryProcessor implements CartProcessorInterface, CartDataCollectorI public function collect(CartDataCollection $data, Cart $original, SalesChannelContext $context, CartBehavior $behavior): void { - //$original->addErrors( new ShippingMethodBlockedError((string) "Foobar collect")); + $this->logger->warning('collect'); + /* ensure we have at least one shipping method key to avoid ShippingMethodNotFoundException */ - $default_ship = $context->getShippingMethod(); - $default_ship_id = $default_ship->getId(); - $key = self::buildKey($default_ship_id); - $data->set($key, $default_ship); + $shipping_method = $context->getShippingMethod(); + $shipping_method_id = $shipping_method->getId(); + $this->logger->warning($shipping_method->getName()); + $key = self::buildKey($shipping_method_id); + $data->set($key, $shipping_method); } public function process(CartDataCollection $data, Cart $original, Cart $toCalculate, SalesChannelContext $context, CartBehavior $behavior): void @@ -53,13 +94,199 @@ class SmcDeliveryProcessor implements CartProcessorInterface, CartDataCollectorI /* below steps 1:1 from original shopware DeliveryProcessor */ $deliveries = $this->builder->build($toCalculate, $data, $context, $behavior); $delivery = $deliveries->first(); - - /* custom computation of costs */ - $costs = new CalculatedPrice(23, 42, new CalculatedTaxCollection(), new TaxRuleCollection()); - $delivery->setShippingCosts($costs); + if ($behavior->hasPermission(self::SKIP_DELIVERY_PRICE_RECALCULATION)) { + $originalDeliveries = $original->getDeliveries(); + $originalDelivery = $originalDeliveries->first(); + if ($delivery !== null && $originalDelivery !== null) { + $originalDelivery->setShippingMethod($delivery->getShippingMethod()); + //Keep old prices + $delivery->setShippingCosts($originalDelivery->getShippingCosts()); + //Recalculate tax + $this->deliveryCalculator->calculate($data, $toCalculate, $deliveries, $context); + $originalDelivery->setShippingCosts($delivery->getShippingCosts()); + } + // New shipping method (if changed) but with old prices + $toCalculate->setDeliveries($originalDeliveries); + return; + } /* below steps 1:1 from original shopware DeliveryProcessor */ - $this->deliveryCalculator->calculate($data, $toCalculate, $deliveries, $context); + $manualShippingCosts = $original->getExtension(self::MANUAL_SHIPPING_COSTS); + if ($delivery !== null && $manualShippingCosts instanceof CalculatedPrice) { + $delivery->setShippingCosts($manualShippingCosts); + } + + /* this is the actual part where we override the shipping costs */ + $this->calculate($data, $toCalculate, $deliveries, $context); + $toCalculate->setDeliveries($deliveries); } + + + /* similar to DeliveryCalculater::calculate */ + private function calculate(CartDataCollection $data, Cart $cart, DeliveryCollection $deliveries, SalesChannelContext $context): void + { + $this->logger->warning('calculate'); + + foreach ($deliveries as $delivery) { + $this->calculateDelivery($data, $cart, $delivery, $context); + } + } + + /* similar to DeliveryCalculater::calculateDelivery */ + private function calculateDelivery(CartDataCollection $data, Cart $cart, Delivery $delivery, SalesChannelContext $context): void + { + $deliver_loc = $delivery->getLocation(); + $cust_addr = $deliver_loc->getAddress(); + if ($cust_addr == null) { + /* address not yet known, cannot determine shipping cost */ + return; + } + $cust_addr_sc = SmcDeliveryProcessor::custAddr2shipcloud($cust_addr, $deliver_loc->getCountry()->getIso()); + + $shipping_method = $context->getShippingMethod(); + $service_sc = SmcDeliveryProcessor::shippingMethod2shipcloud($shipping_method); + + /* compute total weight, volume and monetary value */ + $weight_kg = $delivery->getPositions()->getWithoutDeliveryFree()->getWeight(); + $volume = $delivery->getPositions()->getWithoutDeliveryFree()->getVolume(); + $value = $delivery->getPositions()->getWithoutDeliveryFree()->getPrices()->sum()->getTotalPrice(); + $this->logger->warning("weight_kg: ${weight_kg}, volume: ${volume}, value: ${value}"); + if ($weight_kg == 0) { + $cart->addErrors(new UnableToGetShippingQuoteError("Total weight of delivery cannot be zero")); + return; + } + + /* assume 10% of product weight as packaging weight; minimum 200g */ + if ($weight_kg < 1) { + $tare_kg = 0.2; + } else { + $tare_kg = $weight_kg * 0.1; + } + + /* perform Actual API call with address / weight / volume */ + try { + $quote = $this->shipcloud->shipmentQuotes()->create([ + 'carrier' => 'ups', + 'service' => $service_sc, + 'from' => [ + 'street' => 'Alt-Moabit', + 'street_no' => '93', + 'zip_code' => '10559', + 'city' => 'Berlin', + 'country' => 'DE' + ], + 'to' => $cust_addr_sc, + 'package' => SmcDeliveryProcessor::weight2shipcloudPackage($weight_kg), + ]); + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->logger->warning(print_r($e->getMessage(), True)); + if ($e->getResponse()->getStatusCode() == 422) { + $cart->addErrors( new ShippingMethodBlockedError((string) $shipping_method->getTranslation('name'))); + } else { + $cart->addErrors(new UnableToGetShippingQuoteError($e->getMessage())); + } + return; + } + $quote_eur = $quote['shipment_quote']['price']; + + if ($quote_eur < 5) { + $cart->addErrors(new UnableToGetShippingQuoteError("Rate implausible")); + return; + } + + /* add safety margin of 5% on top of shipcloud quote */ + $margin = (0.05 * $quote_eur); + + /* add transport insurance costs 0.0035 */ + $transport_insurance = (0.0035 * $value); + + /* convert into a gross price, as API returns net */ + $quote_eur_gross = ($quote_eur + $margin + $transport_insurance); + if ($context->getTaxState() === CartPrice::TAX_STATE_GROSS) { + /* FIXME: don't use static 19% but destination country specific rate */ + $quote_eur_gross = $quote_eur_gross * 1.19; + } + + $this->logger->warning("shipcloud_quote 'ups/{$service_sc}': ${quote_eur}, weight_kg: ${weight_kg}, tare_kg: ${tare_kg}, margin_eur: ${margin}, transport_insurance: ${transport_insurance}, gross: ${quote_eur_gross}"); + + /* convert raw float into the right format/object required by shopware */ + $price = $this->costWithTaxes($shipping_method, $delivery->getPositions()->getLineItems(), $context, $quote_eur_gross); + $delivery->setShippingCosts($price); + } + + /* compute the shipping price with taxes (as applicable) as CalculatedPrice object */ + private function costWithTaxes(ShippingMethodEntity $shippingMethod, LineItemCollection $calculatedLineItems, SalesChannelContext $context, $quote_eur): CalculatedPrice + { + switch ($shippingMethod->getTaxType()) { + case ShippingMethodEntity::TAX_TYPE_HIGHEST: + $rules = $calculatedLineItems->getPrices()->getHighestTaxRule(); + break; + case ShippingMethodEntity::TAX_TYPE_FIXED: + $tax = $shippingMethod->getTax(); + if ($tax !== null) { + $rules = $context->buildTaxRules($tax->getId()); + break; + } + // no break + default: + $rules = $this->percentageTaxRuleBuilder->buildRules($calculatedLineItems->getPrices()->sum()); + } + + $q_p_def = new QuantityPriceDefinition($quote_eur, $rules, 1); + return $this->quantityPriceCalculator->calculate($q_p_def, $context); + } + + private static function weight2shipcloudPackage(float $weight_kg) + { + $ret = SmcDeliveryProcessor::estimateDimensions($weight_kg); + $ret['weight'] = $weight_kg; + return $ret; + } + + /* return the cubic root of 'x' */ + private static function cbrt(float $x): float + { + return $x ** (1.0/3); + } + + /* estimate the dimensions of a package given its weight + density */ + private static function estimateDimensions(float $weight_kg, float $density_kg_per_dm3 = 0.5) + { + /* compute volume */ + $volume_dm3 = $weight_kg / $density_kg_per_dm3; + $volume_cm3 = 1000 * $volume_dm3; + + /* compute dimensions assuming l=3x, w=2x, h=1x -> x=6 */ + $x = SmcDeliveryProcessor::cbrt($volume_cm3 / 6); + return ['length' => 3.0*$x, 'width' => 2.0*$x, 'height' => $x]; + } + + /* convert address from internal format to shipcloud REST compatible dict */ + private static function custAddr2shipcloud(CustomerAddressEntity $addr, $country_code) + { + return [ + 'street' => $addr->getStreet(), + 'street_no' => '1', // FIXME + 'zip_code' => $addr->getZipcode(), + 'city' => $addr->getCity(), + 'country' => $country_code + ]; + + } + + private static function shippingMethod2shipcloud($method) + { + switch ($method->getName()) { + case 'UPS Standard': + return 'standard'; + case 'UPS Expedited': + return 'ups_expedited'; + case 'UPS Express Saver': + return 'one_day'; + default: + return 'one_day'; + } + + } } diff --git a/SmcShipcloudLive/src/Core/Checkout/Cart/Error/UnableToGetShippingQuoteError.php b/SmcShipcloudLive/src/Core/Checkout/Cart/Error/UnableToGetShippingQuoteError.php new file mode 100644 index 0000000..521ea54 --- /dev/null +++ b/SmcShipcloudLive/src/Core/Checkout/Cart/Error/UnableToGetShippingQuoteError.php @@ -0,0 +1,42 @@ +key = 'unable-to-get-shipping-quote'; + $this->text = $message; + } + + public function getParameters(): array + { + return ['key' => $this->key, 'text' => $this->text]; + } + + public function getId(): string + { + return $this->key; + } + + public function getMessageKey(): string + { + return $this->key; + } + + public function getLevel(): int + { + return self::LEVEL_ERROR; + } + + public function blockOrder(): bool + { + return true; + } +} diff --git a/SmcShipcloudLive/src/Resources/app/administration/en-GB.json b/SmcShipcloudLive/src/Resources/app/administration/en-GB.json new file mode 100644 index 0000000..99e7580 --- /dev/null +++ b/SmcShipcloudLive/src/Resources/app/administration/en-GB.json @@ -0,0 +1,5 @@ +{ + "checkout": { + "unable-to-get-shipping-quote": "Unable to get shipping quote via API: %text%" + } +} diff --git a/SmcShipcloudLive/src/Resources/config/services.xml b/SmcShipcloudLive/src/Resources/config/services.xml index 90c49ff..b8d9b2f 100644 --- a/SmcShipcloudLive/src/Resources/config/services.xml +++ b/SmcShipcloudLive/src/Resources/config/services.xml @@ -34,6 +34,10 @@ + + + + diff --git a/SmcShipcloudLive/vendor/comyo-media/shipcloud-php b/SmcShipcloudLive/vendor/comyo-media/shipcloud-php new file mode 160000 index 0000000..469e7ab --- /dev/null +++ b/SmcShipcloudLive/vendor/comyo-media/shipcloud-php @@ -0,0 +1 @@ +Subproject commit 469e7abb6d430b8c4cc11bc1ce46ab023bc0b3d8