1 package SL::ShopConnector::Shopware6;
 
   5 use parent qw(SL::ShopConnector::Base);
 
   9 use List::Util qw(first);
 
  14 use SL::Helper::Flash;
 
  15 use SL::Locale::String qw(t8);
 
  17 use Rose::Object::MakeMethods::Generic (
 
  18   'scalar --get_set_init' => [ qw(connector) ],
 
  28                     'shippingMethod' => [],
 
  29                       'shippingOrderAddress' => {
 
  39                 'orderCustomer' => [],
 
  58                   'documents' => {          # currently not used
 
  70          'limit' => $self->config->orders_to_fetch ? $self->config->orders_to_fetch : undef,
 
  74                               'field'      => 'billingAddressId',
 
  75                               'definition' => 'order_address',
 
  76                               'name'       => 'BillingAddress',
 
  82                         'value' => 'open', # open or completed (mind the past)
 
  84                         'field' => 'order.stateMachineState.technicalName'
 
  87         'total-count-mode' => 0
 
  92 # used for get_new_orders and get_one_order
 
  93 sub get_fetched_order_structure {
 
  95   # set known params for the return structure
 
  97       shop_id          => $self->config->id,
 
  98       shop_description => $self->config->description,
 
 101       number_of_orders => 0,
 
 103   return %fetched_order;
 
 107   my ($self, $shop_part, $todo) = @_;
 
 109   #shop_part is passed as a param
 
 110   croak t8("Need a valid Shop Part for updating Part") unless ref($shop_part) eq 'SL::DB::ShopPart';
 
 111   croak t8("Invalid todo for updating Part")           unless $todo =~ m/(price|stock|price_stock|active|all)/;
 
 113   my $part = SL::DB::Part->new(id => $shop_part->part_id)->load;
 
 114   die "Shop Part but no kivi Part?" unless ref $part eq 'SL::DB::Part';
 
 116   my $tax_n_price = $shop_part->get_tax_and_price;
 
 117   my $price       = $tax_n_price->{price};
 
 118   my $taxrate     = $tax_n_price->{tax};
 
 120   # simple calc for both cases, always give sw6 the calculated gross price
 
 122   if ($self->config->pricetype eq 'brutto') {
 
 124     $net   = $price / (1 + $taxrate/100);
 
 125   } elsif ($self->config->pricetype eq 'netto') {
 
 127     $gross = $price * (1 + $taxrate/100);
 
 128   } else { die "Invalid state for price type"; }
 
 131   $update_p->{productNumber} = $part->partnumber;
 
 132   $update_p->{name}          = _u8($part->description);
 
 133   $update_p->{description}   =   $shop_part->shop->use_part_longdescription
 
 135                                : _u8($shop_part->shop_description);
 
 137   # locales simple check for english
 
 138   my $english = SL::DB::Manager::Language->get_first(query => [ description   => { ilike => 'Englisch' },
 
 139                                                         or => [ template_code => { ilike => 'en' } ],
 
 141   if (ref $english eq 'SL::DB::Language') {
 
 142     # add english translation for product
 
 143     # TODO (or not): No Translations for shop_part->shop_description available
 
 144     my $translation = first { $english->id == $_->language_id } @{ $part->translations };
 
 145     $update_p->{translations}->{'en-GB'}->{name}        = _u8($translation->{translation});
 
 146     $update_p->{translations}->{'en-GB'}->{description} = _u8($translation->{longdescription});
 
 149   $update_p->{stock}  = $::form->round_amount($part->onhand, 0) if ($todo =~ m/(stock|all)/);
 
 150   # JSON::true JSON::false
 
 151   # These special values become JSON true and JSON false values, respectively.
 
 152   # You can also use \1 and \0 directly if you want
 
 153   $update_p->{active} = (!$part->obsolete && $part->shop) ? \1 : \0 if ($todo =~ m/(active|all)/);
 
 155   # 1. check if there is already a product
 
 156   my $product_filter = {
 
 159                           'value' => $part->partnumber,
 
 161                           'field' => 'productNumber'
 
 165   my $ret = $self->connector->POST('api/search/product', to_json($product_filter));
 
 166   my $response_code = $ret->responseCode();
 
 167   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 169   my $one_d; # maybe empty
 
 171     $one_d = from_json($ret->responseContent())->{data}->[0];
 
 172   } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 173   # edit or create if not found
 
 176     # we need price object structure and taxId
 
 177     $update_p->{$_} = $one_d->{$_} foreach qw(taxId price);
 
 178     if ($todo =~ m/(price|all)/) {
 
 179       $update_p->{price}->[0]->{gross} = $gross;
 
 181     undef $update_p->{partNumber}; # we dont need this one
 
 182     $ret = $self->connector->PATCH('api/product/' . $one_d->{id}, to_json($update_p));
 
 183     unless (204 == $ret->responseCode()) {
 
 184       die t8('Part Description is too long for this Shopware version. It should have lower than 255 characters.')
 
 185          if $ret->responseContent() =~ m/Diese Zeichenkette ist zu lang. Sie sollte.*255 Zeichen/;
 
 186       die "Updating part with " .  $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
 
 190     # 1. get the correct tax for this product
 
 200     $ret = $self->connector->POST('api/search/tax', to_json($tax_filter));
 
 201     die "Search for Tax with rate: " .  $part->partnumber . " failed: " . $ret->responseContent() unless (200 == $ret->responseCode());
 
 203       $update_p->{taxId} = from_json($ret->responseContent())->{data}->[0]->{id};
 
 204     } catch { die "Malformed JSON Data or Taxkey entry missing: $_ " . $ret->responseContent();  };
 
 206     # 2. get the correct currency for this product
 
 207     my $currency_filter = {
 
 210                         'value' => SL::DB::Default->get_default_currency,
 
 216     $ret = $self->connector->POST('api/search/currency', to_json($currency_filter));
 
 217     die "Search for Currency with ISO Code: " . SL::DB::Default->get_default_currency . " failed: "
 
 218       . $ret->responseContent() unless (200 == $ret->responseCode());
 
 221       $update_p->{price}->[0]->{currencyId} = from_json($ret->responseContent())->{data}->[0]->{id};
 
 222     } catch { die "Malformed JSON Data or Currency ID entry missing: $_ " . $ret->responseContent();  };
 
 224     # 3. add net and gross price and allow variants
 
 225     $update_p->{price}->[0]->{gross}  = $gross;
 
 226     $update_p->{price}->[0]->{net}    = $net;
 
 227     $update_p->{price}->[0]->{linked} = \1; # link product variants
 
 229     $ret = $self->connector->POST('api/product', to_json($update_p));
 
 230     die "Create for Product " .  $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
 
 233   # if there are images try to sync this with the shop_part
 
 235     $self->sync_all_images(shop_part => $shop_part, set_cover => 1, delete_orphaned => 1);
 
 236   } catch { die "Could not sync images for Part " . $part->partnumber . " Reason: $_" };
 
 238   # if there are categories try to sync this with the shop_part
 
 240     $self->sync_all_categories(shop_part => $shop_part);
 
 241   } catch { die "Could not sync Categories for Part " . $part->partnumber . " Reason: $_" };
 
 243   return 1; # no invalid response code -> success
 
 245 sub sync_all_categories {
 
 246   my ($self, %params) = @_;
 
 248   my $shop_part = delete $params{shop_part};
 
 249   croak "Need a valid Shop Part for updating Images" unless ref($shop_part) eq 'SL::DB::ShopPart';
 
 251   my $partnumber = $shop_part->part->partnumber;
 
 252   die "Shop Part but no kivi Partnumber" unless $partnumber;
 
 254   my ($ret, $response_code);
 
 255   # 1 get  uuid for product
 
 256   my $product_filter = {
 
 259                           'value' => $partnumber,
 
 261                           'field' => 'productNumber'
 
 266   $ret = $self->connector->POST('api/search/product', to_json($product_filter));
 
 267   $response_code = $ret->responseCode();
 
 268   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 269   my ($product_id, $category_tree);
 
 271     $product_id    = from_json($ret->responseContent())->{data}->[0]->{id};
 
 272     $category_tree = from_json($ret->responseContent())->{data}->[0]->{categoryIds};
 
 273   } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 275   # if the part is connected to a category at all
 
 276   if ($shop_part->shop_category) {
 
 277     foreach my $row_cat (@{ $shop_part->shop_category }) {
 
 278       $cat->{@{ $row_cat }[0]} = @{ $row_cat }[1];
 
 282   foreach my $shopware_cat (@{ $category_tree }) {
 
 283     if ($cat->{$shopware_cat}) {
 
 284       # cat exists and no delete
 
 285       delete $cat->{$shopware_cat};
 
 288     # cat exists and delete
 
 289     $ret = $self->connector->DELETE("api/product/$product_id/categories/$shopware_cat");
 
 290     $response_code = $ret->responseCode();
 
 291     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
 
 293   # now add only new categories
 
 295   $p->{id}  = $product_id;
 
 296   $p->{categories} = ();
 
 297   foreach my $new_cat (keys %{ $cat }) {
 
 298     push @{ $p->{categories} }, {id => $new_cat};
 
 300     $ret = $self->connector->PATCH("api/product/$product_id", to_json($p));
 
 301     $response_code = $ret->responseCode();
 
 302     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
 
 305 sub sync_all_images {
 
 306   my ($self, %params) = @_;
 
 308   $params{set_cover}       //= 1;
 
 309   $params{delete_orphaned} //= 0;
 
 311   my $shop_part = delete $params{shop_part};
 
 312   croak "Need a valid Shop Part for updating Images" unless ref($shop_part) eq 'SL::DB::ShopPart';
 
 314   my $partnumber = $shop_part->part->partnumber;
 
 315   die "Shop Part but no kivi Partnumber" unless $partnumber;
 
 317   my @upload_img  = $shop_part->get_images(want_binary => 1);
 
 319   return unless (@upload_img); # there are no images, but delete wont work TODO extract to method
 
 321   my ($ret, $response_code);
 
 322   # 1. get part uuid and get media associations
 
 323   # 2. create or update the media entry for the filename
 
 324   # 2.1 if no media entry exists create one
 
 326   # 2.2 create or update media_product and set position
 
 327   # 3. optional set cover image
 
 328   # 4. optional delete images in shopware which are not in kivi
 
 330   # 1 get mediaid uuid for prodcut
 
 331   my $product_filter = {
 
 337                           'value' => $partnumber,
 
 339                           'field' => 'productNumber'
 
 344   $ret = $self->connector->POST('api/search/product', to_json($product_filter));
 
 345   $response_code = $ret->responseCode();
 
 346   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 347   my ($product_id, $media_data);
 
 349     $product_id = from_json($ret->responseContent())->{data}->[0]->{id};
 
 350     # $media_data = from_json($ret->responseContent())->{data}->[0]->{media};
 
 351   } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 353   # 2 iterate all kivi images and save distinct name for later sync
 
 355   foreach my $img (@upload_img) {
 
 356     die $::locale->text("Need a image title") unless $img->{description};
 
 357     my $distinct_media_name = $partnumber . '_' . $img->{description};
 
 358     $existing_images{$distinct_media_name} = 1;
 
 359     my $image_filter = {  'filter' => [
 
 361                             'value' => $distinct_media_name,
 
 363                             'field' => 'fileName'
 
 367     $ret           = $self->connector->POST('api/search/media', to_json($image_filter));
 
 368     $response_code = $ret->responseCode();
 
 369     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 370     my $current_image_id; # maybe empty
 
 372       $current_image_id = from_json($ret->responseContent())->{data}->[0]->{id};
 
 373     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 375     # 2.1 no image with this title, create metadata for media and upload image
 
 376     if (!$current_image_id) {
 
 377       # not yet uploaded, create media entry
 
 378       $ret = $self->connector->POST("/api/media?_response=true");
 
 379       $response_code = $ret->responseCode();
 
 380       die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 382         $current_image_id = from_json($ret->responseContent())->{data}{id};
 
 383       } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 385     # 2.2 update the image data (current_image_id was found or created)
 
 386     $ret = $self->connector->POST("/api/_action/media/$current_image_id/upload?fileName=$distinct_media_name&extension=$img->{extension}",
 
 389                                     "Content-Type"  => "image/$img->{extension}",
 
 391     $response_code = $ret->responseCode();
 
 392     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
 
 394     # 2.3 check if a product media entry exists for this id
 
 395     my $product_media_filter = {
 
 398                           'value' => $product_id,
 
 400                           'field' => 'productId'
 
 403                           'value' => $current_image_id,
 
 409     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
 
 410     $response_code = $ret->responseCode();
 
 411     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 412     my ($has_product_media, $product_media_id);
 
 414       $has_product_media = from_json($ret->responseContent())->{total};
 
 415       $product_media_id  = from_json($ret->responseContent())->{data}->[0]->{id};
 
 416     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 418     # 2.4 ... and either update or create the entry
 
 419     #     set shopware position to kivi position
 
 421     $product_media->{position} = $img->{position}; # position may change
 
 423     if ($has_product_media == 0) {
 
 424       # 2.4.1 new entry. link product to media
 
 425       $product_media->{productId} = $product_id;
 
 426       $product_media->{mediaId}   = $current_image_id;
 
 427       $ret = $self->connector->POST('api/product-media', to_json($product_media));
 
 428     } elsif ($has_product_media == 1 && $product_media_id) {
 
 429       $ret = $self->connector->PATCH("api/product-media/$product_media_id", to_json($product_media));
 
 431       die "Invalid state, please inform Shopware master admin at product-media filter: $product_media_filter";
 
 433     $response_code = $ret->responseCode();
 
 434     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
 
 436   # 3. optional set image with position 1 as cover image
 
 437   if ($params{set_cover}) {
 
 438     # set cover if position == 1
 
 439     my $product_media_filter = {
 
 442                           'value' => $product_id,
 
 444                           'field' => 'productId'
 
 449                           'field' => 'position'
 
 454     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
 
 455     $response_code = $ret->responseCode();
 
 456     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 459       $cover->{coverId} = from_json($ret->responseContent())->{data}->[0]->{id};
 
 460     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 461     $ret = $self->connector->PATCH('api/product/' . $product_id, to_json($cover));
 
 462     $response_code = $ret->responseCode();
 
 463     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
 
 465   # 4. optional delete orphaned images in shopware
 
 466   if ($params{delete_orphaned}) {
 
 467     # delete orphaned images
 
 468     my $product_media_filter = {
 
 471                           'value' => $product_id,
 
 473                           'field' => 'productId'
 
 475     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
 
 476     $response_code = $ret->responseCode();
 
 477     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
 
 480       $img_ary = from_json($ret->responseContent())->{data};
 
 481     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
 
 483     if (scalar @{ $img_ary} > 0) { # maybe no images at all
 
 485       $existing_img{$_->{media}->{fileName}}= $_->{media}->{id} for @{ $img_ary };
 
 487       while (my ($name, $id) = each %existing_img) {
 
 488         next if $existing_images{$name};
 
 489         $ret = $self->connector->DELETE("api/media/$id");
 
 490         $response_code = $ret->responseCode();
 
 491         die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
 
 501   my $ret           = $self->connector->POST('api/search/category');
 
 502   my $response_code = $ret->responseCode();
 
 504   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
 
 508     $import = decode_json $ret->responseContent();
 
 510     die "Malformed JSON Data: $_ " . $ret->responseContent();
 
 513   my @daten      = @{ $import->{data} };
 
 514   my %categories = map { ($_->{id} => $_) } @daten;
 
 518     my $parent = $categories{$_->{parentId}};
 
 520       $parent->{children} ||= [];
 
 521       push @{ $parent->{children} }, $_;
 
 523       push @categories_tree, $_;
 
 526   return \@categories_tree;
 
 530   my ($self, $ordnumber) = @_;
 
 532   croak t8("No Order Number") unless $ordnumber;
 
 533   # set known params for the return structure
 
 534   my %fetched_order  = $self->get_fetched_order_structure;
 
 535   my $assoc          = $self->all_open_orders();
 
 537   # overwrite filter for exactly one ordnumber
 
 538   $assoc->{filter}->[0]->{value} = $ordnumber;
 
 539   $assoc->{filter}->[0]->{type}  = 'equals';
 
 540   $assoc->{filter}->[0]->{field} = 'orderNumber';
 
 542   # 1. fetch the order and import it as a kivi order
 
 543   # 2. return the number of processed order (1)
 
 544   my $one_order = $self->connector->POST('api/search/order', to_json($assoc));
 
 546   # 1. check for bad request or connection problems
 
 547   if ($one_order->responseCode() != 200) {
 
 548     $fetched_order{error}   = 1;
 
 549     $fetched_order{message} = $one_order->responseCode() . ' ' . $one_order->responseContent();
 
 550     return \%fetched_order;
 
 553   # 1.1 parse json or exit
 
 556     $content = from_json($one_order->responseContent());
 
 558     $fetched_order{error}   = 1;
 
 559     $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
 
 560     return \%fetched_order;
 
 563   # 2. check if we found ONE order at all
 
 564   my $total = $content->{total};
 
 566     $fetched_order{number_of_orders} = 0;
 
 567     return \%fetched_order;
 
 568   } elsif ($total != 1) {
 
 569     $fetched_order{error}   = 1;
 
 570     $fetched_order{message} = "More than one Order returned. Invalid State: $total";
 
 571     return \%fetched_order;
 
 574   # 3. there is one valid order, try to import this one
 
 575   if ($self->import_data_to_shop_order($content->{data}->[0])) {
 
 576     %fetched_order = (shop_description => $self->config->description, number_of_orders => 1);
 
 578     $fetched_order{message} = "Error: $@";
 
 579     $fetched_order{error}   = 1;
 
 581   return \%fetched_order;
 
 587   my %fetched_order  = $self->get_fetched_order_structure;
 
 588   my $assoc          = $self->all_open_orders();
 
 590   # 1. fetch all open orders and try to import it as a kivi order
 
 591   # 2. return the number of processed order $total
 
 592   my $open_orders = $self->connector->POST('api/search/order', to_json($assoc));
 
 594   # 1. check for bad request or connection problems
 
 595   if ($open_orders->responseCode() != 200) {
 
 596     $fetched_order{error}   = 1;
 
 597     $fetched_order{message} = $open_orders->responseCode() . ' ' . $open_orders->responseContent();
 
 598     return \%fetched_order;
 
 601   # 1.1 parse json or exit
 
 604     $content = from_json($open_orders->responseContent());
 
 606     $fetched_order{error}   = 1;
 
 607     $fetched_order{message} = "Malformed JSON Data: $_ " . $open_orders->responseContent();
 
 608     return \%fetched_order;
 
 611   # 2. check if we found one or more order at all
 
 612   my $total = $content->{total};
 
 614     $fetched_order{number_of_orders} = 0;
 
 615     return \%fetched_order;
 
 616   } elsif (!$total || !($total > 0)) {
 
 617     $fetched_order{error}   = 1;
 
 618     $fetched_order{message} = "Undefined value for total orders returned. Invalid State: $total";
 
 619     return \%fetched_order;
 
 622   # 3. there are open orders. try to import one by one
 
 623   $fetched_order{number_of_orders} = 0;
 
 624   foreach my $open_order (@{ $content->{data} }) {
 
 625     if ($self->import_data_to_shop_order($open_order)) {
 
 626       $fetched_order{number_of_orders}++;
 
 628       $fetched_order{message} .= "Error at importing order with running number:"
 
 629                                   . $fetched_order{number_of_orders}+1 . ": $@ \n";
 
 630       $fetched_order{error}    = 1;
 
 633   return \%fetched_order;
 
 637   my ($self, $partnumber) = @_;
 
 639   $partnumber   = $::form->escape($partnumber);
 
 640   my $product_filter = {
 
 643                               'value' => $partnumber,
 
 645                               'field' => 'productNumber'
 
 649   my $ret = $self->connector->POST('api/search/product', to_json($product_filter));
 
 651   my $response_code = $ret->responseCode();
 
 652   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
 
 656     $data_json = decode_json $ret->responseContent();
 
 658     die "Malformed JSON Data: $_ " . $ret->responseContent();
 
 661   # maybe no product was found ...
 
 662   return undef unless scalar @{ $data_json->{data} } > 0;
 
 663   # caller wants this structure:
 
 664   # $stock_onlineshop = $shop_article->{data}->{mainDetail}->{inStock};
 
 665   # $active_online = $shop_article->{data}->{active};
 
 667   $data->{data}->{mainDetail}->{inStock} = $data_json->{data}->[0]->{stock};
 
 668   $data->{data}->{active}                = $data_json->{data}->[0]->{active};
 
 675   my $return  = {}; # return for caller
 
 676   my $ret     = {}; # internal return
 
 678   #  1. check if we can connect at all
 
 679   #  2. request version number
 
 681   $ret = $self->connector;
 
 682   if (200 != $ret->responseCode()) {
 
 683     $return->{success}         = 0;
 
 684     $return->{data}->{version} = $self->{errors}; # whatever init puts in errors
 
 688   $ret = $self->connector->GET('api/_info/version');
 
 689   if (200 == $ret->responseCode()) {
 
 690     my $version = from_json($self->connector->responseContent())->{version};
 
 691     $return->{success}         = 1;
 
 692     $return->{data}->{version} = $version;
 
 694     $return->{success}         = 0;
 
 695     $return->{data}->{version} = $ret->responseContent(); # Whatever REST Interface says
 
 701 sub set_orderstatus {
 
 702   my ($self, $order_id, $transition) = @_;
 
 705   $transition = 'complete' if $transition eq 'completed';
 
 707   croak "No shop order ID, should be in format [0-9a-f]{32}" unless $order_id   =~ m/^[0-9a-f]{32}$/;
 
 708   croak "NO valid transition value"                          unless $transition =~ m/(open|process|cancel|complete)/;
 
 710   $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
 
 711   my $response_code = $ret->responseCode();
 
 712   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
 
 719   my $protocol = $self->config->server =~ /(^https:\/\/|^http:\/\/)/ ? '' : $self->config->protocol . '://';
 
 720   my $client   = REST::Client->new(host => $protocol . $self->config->server);
 
 722   $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
 
 723   $client->addHeader('Content-Type', 'application/json');
 
 724   $client->addHeader('charset',      'UTF-8');
 
 725   $client->addHeader('Accept',       'application/json');
 
 728                    client_id     => $self->config->login,
 
 729                    client_secret => $self->config->password,
 
 730                    grant_type    => "client_credentials",
 
 733   my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
 
 735   unless (200 == $ret->responseCode()) {
 
 736     $self->{errors} .= $ret->responseContent();
 
 740   my $token = from_json($client->responseContent())->{access_token};
 
 742     $self->{errors} .= "No Auth-Token received";
 
 745   # persist refresh token
 
 746   $client->addHeader('Authorization' => 'Bearer ' . $token);
 
 750 sub import_data_to_shop_order {
 
 751   my ($self, $import) = @_;
 
 753   # failsafe checks for not yet implemented
 
 754   die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
 
 756   # no mapping unless we also have at least one shop order item ...
 
 757   my $order_pos = delete $import->{lineItems};
 
 758   croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
 
 760   my $shop_order = $self->map_data_to_shoporder($import);
 
 762   my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
 
 764     my $id = $shop_order->id;
 
 766     my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
 
 768     my $active_price_source = $self->config->price_source;
 
 770     foreach my $pos (@positions) {
 
 772       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
 
 773       my %pos_columns = ( description          => $pos->{product}->{name},
 
 774                           partnumber           => $pos->{product}->{productNumber},
 
 776                           quantity             => $pos->{quantity},
 
 777                           position             => $position,
 
 778                           tax_rate             => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
 
 779                           shop_trans_id        => $pos->{id}, # pos id or shop_trans_id ? or dont care?
 
 780                           shop_order_id        => $id,
 
 781                           active_price_source  => $active_price_source,
 
 783       my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
 
 786     $shop_order->positions($position);
 
 788     if ( $self->config->shipping_costs_parts_id ) {
 
 789       die t8("Not yet implemented");
 
 790       # TODO NOT YET Implemented nor tested, this is shopware5 code:
 
 791       my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
 
 792       my %shipping_pos = ( description    => $import->{data}->{dispatch}->{name},
 
 793                            partnumber     => $shipping_part->partnumber,
 
 794                            price          => $import->{data}->{invoiceShipping},
 
 796                            position       => $position,
 
 798                            shop_order_id  => $id,
 
 800       my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
 
 801       $shipping_pos_insert->save;
 
 804     my $customer = $shop_order->get_customer;
 
 806     if (ref $customer eq 'SL::DB::Customer') {
 
 807       $shop_order->kivi_customer_id($customer->id);
 
 811     # update state in shopware before transaction ends
 
 812     $self->set_orderstatus($shop_order->shop_trans_id, "process");
 
 816   }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
 
 817                 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
 
 820 sub map_data_to_shoporder {
 
 821   my ($self, $import) = @_;
 
 823   croak "Expect a hash with one order." unless ref $import eq 'HASH';
 
 824   # we need one number and a order date, some total prices and one customer
 
 825   croak "Does not look like a shopware6 order" unless    $import->{orderNumber}
 
 826                                                       && $import->{orderDateTime}
 
 827                                                       && ref $import->{price} eq 'HASH'
 
 828                                                       && ref $import->{orderCustomer} eq 'HASH';
 
 830   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
 
 831   die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
 
 833   my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} }       @{ $import->{addresses} } ];
 
 834   my $shipto_ary  = [ grep { $_->{id} == $shipto_id }                        @{ $import->{addresses} } ];
 
 835   my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} }        @{ $import->{paymentMethods} } ];
 
 837   die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
 
 838           $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
 
 839     unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
 
 841   my $billing = $billing_ary->[0];
 
 842   my $shipto  = $shipto_ary->[0];
 
 843   # TODO payment info is not used at all
 
 844   my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
 
 846   # check mandatory fields from shopware
 
 847   die t8("No billing city")   unless $billing->{city};
 
 848   die t8("No shipto city")    unless $shipto->{city};
 
 849   die t8("No customer email") unless $import->{orderCustomer}->{email};
 
 852   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
 
 854                                                time_zone => 'local'             );
 
 857     $orderdate = $parser->parse_datetime($import->{orderDateTime});
 
 858   } catch { die "Cannot parse Order Date" . $_ };
 
 860   my $shop_id      = $self->config->id;
 
 861   my $tax_included = $self->config->pricetype;
 
 863   # TODO copied from shopware5 connector
 
 864   # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
 
 865   my %payment_ids_methods = (
 
 866     # shopware_paymentId => kivitendo_payment_id
 
 868   my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
 
 869   my $default_payment_id = $default_payment ? $default_payment->id : undef;
 
 874     amount                  => $import->{amountTotal},
 
 875     billing_city            => $billing->{city},
 
 876     billing_company         => $billing->{company},
 
 877     billing_country         => $billing->{country}->{name},
 
 878     billing_department      => $billing->{department},
 
 879     billing_email           => $import->{orderCustomer}->{email},
 
 880     billing_fax             => $billing->{fax},
 
 881     billing_firstname       => $billing->{firstName},
 
 882     #billing_greeting        => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
 
 883     billing_lastname        => $billing->{lastName},
 
 884     billing_phone           => $billing->{phone},
 
 885     billing_street          => $billing->{street},
 
 886     billing_vat             => $billing->{vatId},
 
 887     billing_zipcode         => $billing->{zipcode},
 
 888     customer_city           => $billing->{city},
 
 889     customer_company        => $billing->{company},
 
 890     customer_country        => $billing->{country}->{name},
 
 891     customer_department     => $billing->{department},
 
 892     customer_email          => $billing->{email},
 
 893     customer_fax            => $billing->{fax},
 
 894     customer_firstname      => $billing->{firstName},
 
 895     #customer_greeting       => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
 
 896     customer_lastname       => $billing->{lastName},
 
 897     customer_phone          => $billing->{phoneNumber},
 
 898     customer_street         => $billing->{street},
 
 899     customer_vat            => $billing->{vatId},
 
 900     customer_zipcode        => $billing->{zipcode},
 
 901 #    customer_newsletter     => $customer}->{newsletter},
 
 902     delivery_city           => $shipto->{city},
 
 903     delivery_company        => $shipto->{company},
 
 904     delivery_country        => $shipto->{country}->{name},
 
 905     delivery_department     => $shipto->{department},
 
 906     delivery_email          => "",
 
 907     delivery_fax            => $shipto->{fax},
 
 908     delivery_firstname      => $shipto->{firstName},
 
 909     #delivery_greeting       => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
 
 910     delivery_lastname       => $shipto->{lastName},
 
 911     delivery_phone          => $shipto->{phone},
 
 912     delivery_street         => $shipto->{street},
 
 913     delivery_vat            => $shipto->{vatId},
 
 914     delivery_zipcode        => $shipto->{zipCode},
 
 915 #    host                    => $shop}->{hosts},
 
 916     netamount               => $import->{amountNet},
 
 917     order_date              => $orderdate,
 
 918     payment_description     => $payment->{name},
 
 919     payment_id              => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
 
 920     tax_included            => $tax_included eq "brutto" ? 1 : 0,
 
 921     shop_ordernumber        => $import->{orderNumber},
 
 923     shop_trans_id           => $import->{id},
 
 925     #remote_ip               => $import->{remoteAddress},
 
 926     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
 
 927     #sepa_bic                => $import->{paymentIntances}->{bic},
 
 928     #sepa_iban               => $import->{paymentIntances}->{iban},
 
 929     #shipping_costs          => $import->{invoiceShipping},
 
 930     #shipping_costs_net      => $import->{invoiceShippingNet},
 
 931     #shop_c_billing_id       => $import->{billing}->{customerId},
 
 932     #shop_c_billing_number   => $import->{billing}->{number},
 
 933     #shop_c_delivery_id      => $import->{shipping}->{id},
 
 934     #shop_customer_id        => $import->{customerId},
 
 935     #shop_customer_number    => $import->{billing}->{number},
 
 936     #shop_customer_comment   => $import->{customerComment},
 
 939   my $shop_order = SL::DB::ShopOrder->new(%columns);
 
 945   return encode('UTF-8', $value // '');
 
 956   SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
 
 963 =head1 AVAILABLE METHODS
 
 967 =item C<get_one_order>
 
 969 =item C<get_new_orders>
 
 973 Updates all metadata for a shop part. See base class for a general description.
 
 974 Specific Implementation notes:
 
 977 =item Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
 
 979 =item Checks if longdescription should be taken from part or shop_part
 
 981 =item Checks if a language with the name 'Englisch' or template_code 'en'
 
 982       is available and sets the shopware6 'en-GB' locales for the product
 
 984 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
 
 986 The connecting key for shopware to kivi images is the image name.
 
 987 To get distinct entries the kivi partnumber is combined with the title (description)
 
 988 of the image. Therefore part1000_someTitlefromUser should be unique in
 
 990 All image data is simply send to shopware whether or not image data
 
 991 has been edited recently.
 
 992 If set_cover is set, the image with the position 1 will be used as
 
 993 the shopware cover image.
 
 994 If delete_orphaned ist set, all images related to the shopware product
 
 995 which are not also in kivitendo will be deleted.
 
 996 Shopware (6.4.x) takes care of deleting all the relations if the media
 
 997 entry for the image is deleted.
 
 998 More on media and Shopware6 can be found here:
 
 999 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
 
1005 =item C<get_article>
 
1007 =item C<get_categories>
 
1009 =item C<get_version>
 
1011 Tries to establish a connection and in a second step
 
1012 tries to get the server's version number.
 
1013 Returns a hashref with the data structure the Base class expects.
 
1015 =item C<set_orderstatus>
 
1017 =item C<init_connector>
 
1019 Inits the connection to the REST Server.
 
1020 Errors are collected in $self->{errors} and undef will be returned.
 
1021 If successful returns a REST::Client object for further communications.
 
1027 L<SL::ShopConnector::ALL>
 
1037 =item * Map all data to shop_order
 
1039 Missing fields are commented in the sub map_data_to_shoporder.
 
1040 Some items are SEPA debit info, IP adress, delivery costs etc
 
1041 Furthermore Shopware6 uses currency, country and locales information.
 
1044     #customer_newsletter     => $customer}->{newsletter},
 
1045     #remote_ip               => $import->{remoteAddress},
 
1046     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
 
1047     #sepa_bic                => $import->{paymentIntances}->{bic},
 
1048     #sepa_iban               => $import->{paymentIntances}->{iban},
 
1049     #shipping_costs          => $import->{invoiceShipping},
 
1050     #shipping_costs_net      => $import->{invoiceShippingNet},
 
1051     #shop_c_billing_id       => $import->{billing}->{customerId},
 
1052     #shop_c_billing_number   => $import->{billing}->{number},
 
1053     #shop_c_delivery_id      => $import->{shipping}->{id},
 
1054     #shop_customer_id        => $import->{customerId},
 
1055     #shop_customer_number    => $import->{billing}->{number},
 
1056     #shop_customer_comment   => $import->{customerComment},
 
1058 =item * Use shipping_costs_parts_id for additional shipping costs
 
1060 Currently dies if a shipping_costs_parts_id is set in the config
 
1062 =item * Payment Infos can be read from shopware but is not linked with kivi
 
1064 Unused data structures in sub map_data_to_shoporder => payment_ary
 
1066 =item * Delete orphaned images is new in this connector, but should be in a separate method
 
1068 =item * Fetch from last order number is ignored and should not be needed
 
1070 Fetch orders also sets the state of the order from open to process. The state setting
 
1071 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
 
1072 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
 
1073 and ignores any shopware order transition state.
 
1075 =item * Get one order and get new orders is basically the same except for the filter
 
1077 Right now the returning structure and the common parts of the filter are in two separate functions
 
1081 Many error messages are thrown, but at least the more common cases should be localized.
 
1083 =item * Multi language support
 
1085 By guessing the correct german name for the english language some translation for parts can
 
1086 also be synced. This should be more clear (language configuration for shops) and the order
 
1087 synchronisation should also handle this (longdescription is simply copied from part.notes)
 
1093 Jan Büren jan@kivitendo.de