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