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 # get media folder id
378 $ret = $self->connector->GET('api/media-folder');
379 $response_code = $ret->responseCode();
380 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
383 $media_folder_id = from_json($ret->responseContent())->{data}->[0]->{id};
384 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
386 # not yet uploaded, create media entry
387 $ret = $self->connector->POST("/api/media?_response=true", to_json({"mediaFolderId" => $media_folder_id}));
388 $response_code = $ret->responseCode();
389 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
391 $current_image_id = from_json($ret->responseContent())->{data}{id};
392 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
394 # 2.2 update the image data (current_image_id was found or created)
395 $ret = $self->connector->POST("/api/_action/media/$current_image_id/upload?fileName=$distinct_media_name&extension=$img->{extension}",
398 "Content-Type" => "image/$img->{extension}",
400 $response_code = $ret->responseCode();
401 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
403 # 2.3 check if a product media entry exists for this id
404 my $product_media_filter = {
407 'value' => $product_id,
409 'field' => 'productId'
412 'value' => $current_image_id,
418 $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
419 $response_code = $ret->responseCode();
420 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
421 my ($has_product_media, $product_media_id);
423 $has_product_media = from_json($ret->responseContent())->{total};
424 $product_media_id = from_json($ret->responseContent())->{data}->[0]->{id};
425 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
427 # 2.4 ... and either update or create the entry
428 # set shopware position to kivi position
430 $product_media->{position} = $img->{position}; # position may change
432 if ($has_product_media == 0) {
433 # 2.4.1 new entry. link product to media
434 $product_media->{productId} = $product_id;
435 $product_media->{mediaId} = $current_image_id;
436 $ret = $self->connector->POST('api/product-media', to_json($product_media));
437 } elsif ($has_product_media == 1 && $product_media_id) {
438 $ret = $self->connector->PATCH("api/product-media/$product_media_id", to_json($product_media));
440 die "Invalid state, please inform Shopware master admin at product-media filter: $product_media_filter";
442 $response_code = $ret->responseCode();
443 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
445 # 3. optional set image with position 1 as cover image
446 if ($params{set_cover}) {
447 # set cover if position == 1
448 my $product_media_filter = {
451 'value' => $product_id,
453 'field' => 'productId'
458 'field' => 'position'
463 $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
464 $response_code = $ret->responseCode();
465 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
468 $cover->{coverId} = from_json($ret->responseContent())->{data}->[0]->{id};
469 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
470 $ret = $self->connector->PATCH('api/product/' . $product_id, to_json($cover));
471 $response_code = $ret->responseCode();
472 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
474 # 4. optional delete orphaned images in shopware
475 if ($params{delete_orphaned}) {
476 # delete orphaned images
477 my $product_media_filter = {
480 'value' => $product_id,
482 'field' => 'productId'
484 $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
485 $response_code = $ret->responseCode();
486 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
489 $img_ary = from_json($ret->responseContent())->{data};
490 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
492 if (scalar @{ $img_ary} > 0) { # maybe no images at all
494 $existing_img{$_->{media}->{fileName}}= $_->{media}->{id} for @{ $img_ary };
496 while (my ($name, $id) = each %existing_img) {
497 next if $existing_images{$name};
498 $ret = $self->connector->DELETE("api/media/$id");
499 $response_code = $ret->responseCode();
500 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
510 my $ret = $self->connector->POST('api/search/category');
511 my $response_code = $ret->responseCode();
513 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
517 $import = decode_json $ret->responseContent();
519 die "Malformed JSON Data: $_ " . $ret->responseContent();
522 my @daten = @{ $import->{data} };
523 my %categories = map { ($_->{id} => $_) } @daten;
527 my $parent = $categories{$_->{parentId}};
529 $parent->{children} ||= [];
530 push @{ $parent->{children} }, $_;
532 push @categories_tree, $_;
535 return \@categories_tree;
539 my ($self, $ordnumber) = @_;
541 croak t8("No Order Number") unless $ordnumber;
542 # set known params for the return structure
543 my %fetched_order = $self->get_fetched_order_structure;
544 my $assoc = $self->all_open_orders();
546 # overwrite filter for exactly one ordnumber
547 $assoc->{filter}->[0]->{value} = $ordnumber;
548 $assoc->{filter}->[0]->{type} = 'equals';
549 $assoc->{filter}->[0]->{field} = 'orderNumber';
551 # 1. fetch the order and import it as a kivi order
552 # 2. return the number of processed order (1)
553 my $one_order = $self->connector->POST('api/search/order', to_json($assoc));
555 # 1. check for bad request or connection problems
556 if ($one_order->responseCode() != 200) {
557 $fetched_order{error} = 1;
558 $fetched_order{message} = $one_order->responseCode() . ' ' . $one_order->responseContent();
559 return \%fetched_order;
562 # 1.1 parse json or exit
565 $content = from_json($one_order->responseContent());
567 $fetched_order{error} = 1;
568 $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
569 return \%fetched_order;
572 # 2. check if we found ONE order at all
573 my $total = $content->{total};
575 $fetched_order{number_of_orders} = 0;
576 return \%fetched_order;
577 } elsif ($total != 1) {
578 $fetched_order{error} = 1;
579 $fetched_order{message} = "More than one Order returned. Invalid State: $total";
580 return \%fetched_order;
583 # 3. there is one valid order, try to import this one
584 if ($self->import_data_to_shop_order($content->{data}->[0])) {
585 %fetched_order = (shop_description => $self->config->description, number_of_orders => 1);
587 $fetched_order{message} = "Error: $@";
588 $fetched_order{error} = 1;
590 return \%fetched_order;
596 my %fetched_order = $self->get_fetched_order_structure;
597 my $assoc = $self->all_open_orders();
599 # 1. fetch all open orders and try to import it as a kivi order
600 # 2. return the number of processed order $total
601 my $open_orders = $self->connector->POST('api/search/order', to_json($assoc));
603 # 1. check for bad request or connection problems
604 if ($open_orders->responseCode() != 200) {
605 $fetched_order{error} = 1;
606 $fetched_order{message} = $open_orders->responseCode() . ' ' . $open_orders->responseContent();
607 return \%fetched_order;
610 # 1.1 parse json or exit
613 $content = from_json($open_orders->responseContent());
615 $fetched_order{error} = 1;
616 $fetched_order{message} = "Malformed JSON Data: $_ " . $open_orders->responseContent();
617 return \%fetched_order;
620 # 2. check if we found one or more order at all
621 my $total = $content->{total};
623 $fetched_order{number_of_orders} = 0;
624 return \%fetched_order;
625 } elsif (!$total || !($total > 0)) {
626 $fetched_order{error} = 1;
627 $fetched_order{message} = "Undefined value for total orders returned. Invalid State: $total";
628 return \%fetched_order;
631 # 3. there are open orders. try to import one by one
632 $fetched_order{number_of_orders} = 0;
633 foreach my $open_order (@{ $content->{data} }) {
634 if ($self->import_data_to_shop_order($open_order)) {
635 $fetched_order{number_of_orders}++;
637 $fetched_order{message} .= "Error at importing order with running number:"
638 . $fetched_order{number_of_orders}+1 . ": $@ \n";
639 $fetched_order{error} = 1;
642 return \%fetched_order;
646 my ($self, $partnumber) = @_;
648 $partnumber = $::form->escape($partnumber);
649 my $product_filter = {
652 'value' => $partnumber,
654 'field' => 'productNumber'
658 my $ret = $self->connector->POST('api/search/product', to_json($product_filter));
660 my $response_code = $ret->responseCode();
661 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
665 $data_json = decode_json $ret->responseContent();
667 die "Malformed JSON Data: $_ " . $ret->responseContent();
670 # maybe no product was found ...
671 return undef unless scalar @{ $data_json->{data} } > 0;
672 # caller wants this structure:
673 # $stock_onlineshop = $shop_article->{data}->{mainDetail}->{inStock};
674 # $active_online = $shop_article->{data}->{active};
676 $data->{data}->{mainDetail}->{inStock} = $data_json->{data}->[0]->{stock};
677 $data->{data}->{active} = $data_json->{data}->[0]->{active};
684 my $return = {}; # return for caller
685 my $ret = {}; # internal return
687 # 1. check if we can connect at all
688 # 2. request version number
690 $ret = $self->connector;
691 if (!defined $ret || 200 != $ret->responseCode()) {
692 $return->{success} = 0;
693 $return->{data}->{version} = $self->{errors}; # whatever init puts in errors
697 $ret = $self->connector->GET('api/_info/version');
698 if (200 == $ret->responseCode()) {
699 my $version = from_json($self->connector->responseContent())->{version};
700 $return->{success} = 1;
701 $return->{data}->{version} = $version;
703 $return->{success} = 0;
704 $return->{data}->{version} = $ret->responseContent(); # Whatever REST Interface says
710 sub set_orderstatus {
711 my ($self, $order_id, $transition) = @_;
714 $transition = 'complete' if $transition eq 'completed';
716 croak "No shop order ID, should be in format [0-9a-f]{32}" unless $order_id =~ m/^[0-9a-f]{32}$/;
717 croak "NO valid transition value" unless $transition =~ m/(open|process|cancel|complete)/;
719 $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
720 my $response_code = $ret->responseCode();
721 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
724 sub set_order_transaction_status {
725 my ($self, $ordnumber, $transition) = @_;
727 croak t8("No Order Number") unless $ordnumber;
728 croak "NO valid transition value" unless $transition =~ m/(paid)/;
730 # first fetch order_transaction id
731 my %fetched_order = $self->get_fetched_order_structure;
732 my $assoc = $self->all_open_orders();
734 # overwrite filter for exactly one ordnumber
735 $assoc->{filter}->[0]->{value} = $ordnumber;
736 $assoc->{filter}->[0]->{type} = 'equals';
737 $assoc->{filter}->[0]->{field} = 'orderNumber';
739 # 1. fetch the order and import it as a kivi order
740 # 2. return the number of processed order (1)
741 my $one_order = $self->connector->POST('api/search/order', to_json($assoc));
742 # 1. check for bad request or connection problems
743 if ($one_order->responseCode() != 200) {
744 $fetched_order{error} = 1;
745 $fetched_order{message} = $one_order->responseCode() . ' ' . $one_order->responseContent();
746 die "Invalid response code:" . $fetched_order{message};
749 # 1.1 parse json or exit
752 $content = from_json($one_order->responseContent());
754 $fetched_order{error} = 1;
755 $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
756 die "Invalid JSON Data:" . $fetched_order{message};
759 # 2. check if we found ONE order at all
760 my $total = $content->{total};
762 $fetched_order{number_of_orders} = 0;
764 } elsif ($total != 1) {
765 $fetched_order{error} = 1;
766 $fetched_order{message} = "More than one Order returned. Invalid State: $total";
767 die "Invalid State:" . $fetched_order{message};
769 # we assume just one transaction at all
770 die "Can only sync one single Transaction " unless scalar @{ $content->{data}->[0]->{transactions} } == 1;
772 my $order_transaction_id = $content->{data}->[0]->{transactions}->[0]->{id};
774 $ret = $self->connector->POST("/api/_action/order_transaction/$order_transaction_id/state/$transition");
776 my $response_code = $ret->responseCode();
777 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
784 my $protocol = $self->config->server =~ /(^https:\/\/|^http:\/\/)/ ? '' : $self->config->protocol . '://';
785 my $client = REST::Client->new(host => $protocol . $self->config->server);
787 $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
788 $client->addHeader('Content-Type', 'application/json');
789 $client->addHeader('charset', 'UTF-8');
790 $client->addHeader('Accept', 'application/json');
793 client_id => $self->config->login,
794 client_secret => $self->config->password,
795 grant_type => "client_credentials",
798 my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
800 unless (200 == $ret->responseCode()) {
801 $self->{errors} .= $ret->responseContent();
805 my $token = from_json($client->responseContent())->{access_token};
807 $self->{errors} .= "No Auth-Token received";
810 # persist refresh token
811 $client->addHeader('Authorization' => 'Bearer ' . $token);
815 sub import_data_to_shop_order {
816 my ($self, $import) = @_;
818 # failsafe checks for not yet implemented
819 die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
821 # no mapping unless we also have at least one shop order item ...
822 my $order_pos = delete $import->{lineItems};
823 croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
825 my $shop_order = $self->map_data_to_shoporder($import);
827 my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
829 my $id = $shop_order->id;
831 my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
833 my $active_price_source = $self->config->price_source;
835 my %discount_identifier;
836 foreach my $pos (@positions) {
837 if ($pos->{type} eq 'promotion') {
838 next unless $pos->{payload}->{discountType} eq 'percentage';
839 foreach my $discount_pos (@{ $pos->{payload}->{composition} }) {
840 $discount_identifier{$discount_pos->{id}} = { discount_percentage => $pos->{payload}->{value},
841 discount_code => $pos->{payload}->{code} };
846 my $price = $::form->round_amount($pos->{unitPrice}, 2); # unit
847 my %pos_columns = ( description => $pos->{product}->{name},
848 partnumber => $pos->{product}->{productNumber},
850 quantity => $pos->{quantity},
851 position => $position,
852 tax_rate => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
853 shop_trans_id => $pos->{id}, # pos id or shop_trans_id ? or dont care?
854 shop_order_id => $id,
855 active_price_source => $active_price_source,
856 identifier => $pos->{identifier},
858 my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
861 # add discount if percentage
862 while ((my $identifier, my $discount_ref) = each (%discount_identifier)) {
863 # load and update shop order position
864 my $soi = SL::DB::Manager::ShopOrderItem->find_by(identifier => $identifier);
865 die "No Shop Order Item for discount found! identfier: " . $identifier unless ref $soi eq 'SL::DB::ShopOrderItem';
867 $soi->update_attributes(discount => $discount_ref->{discount_percentage} / 100,
868 discount_code => $discount_ref->{discount_code} );
870 $shop_order->positions($position);
872 if ( $self->config->shipping_costs_parts_id ) {
873 die t8("Not yet implemented");
874 # TODO NOT YET Implemented nor tested, this is shopware5 code:
875 my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
876 my %shipping_pos = ( description => $import->{data}->{dispatch}->{name},
877 partnumber => $shipping_part->partnumber,
878 price => $import->{data}->{invoiceShipping},
880 position => $position,
882 shop_order_id => $id,
884 my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
885 $shipping_pos_insert->save;
888 my $customer = $shop_order->get_customer;
890 if (ref $customer eq 'SL::DB::Customer') {
891 $shop_order->kivi_customer_id($customer->id);
895 # update state in shopware before transaction ends
896 $self->set_orderstatus($shop_order->shop_trans_id, "process");
900 }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
901 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
904 sub map_data_to_shoporder {
905 my ($self, $import) = @_;
907 croak "Expect a hash with one order." unless ref $import eq 'HASH';
908 # we need one number and a order date, some total prices and one customer
909 croak "Does not look like a shopware6 order" unless $import->{orderNumber}
910 && $import->{orderDateTime}
911 && ref $import->{price} eq 'HASH'
912 && ref $import->{orderCustomer} eq 'HASH';
914 my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
915 die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
917 my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} } @{ $import->{addresses} } ];
918 my $shipto_ary = [ grep { $_->{id} == $shipto_id } @{ $import->{addresses} } ];
919 my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} } @{ $import->{paymentMethods} } ];
921 die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
922 $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
923 unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
925 my $billing = $billing_ary->[0];
926 my $shipto = $shipto_ary->[0];
927 # TODO payment info is not used at all
928 my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
930 # check mandatory fields from shopware
931 die t8("No billing city") unless $billing->{city};
932 die t8("No shipto city") unless $shipto->{city};
933 die t8("No customer email") unless $import->{orderCustomer}->{email};
936 my $parser = DateTime::Format::Strptime->new(pattern => '%Y-%m-%dT%H:%M:%S',
938 time_zone => 'local' );
941 $orderdate = $parser->parse_datetime($import->{orderDateTime});
942 } catch { die "Cannot parse Order Date" . $_ };
944 my $shop_id = $self->config->id;
945 my $tax_included = $self->config->pricetype;
947 # TODO copied from shopware5 connector
948 # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
949 my %payment_ids_methods = (
950 # shopware_paymentId => kivitendo_payment_id
952 my $default_payment = SL::DB::Manager::PaymentTerm->get_first();
953 my $default_payment_id = $default_payment ? $default_payment->id : undef;
958 amount => $import->{amountTotal},
959 billing_city => $billing->{city},
960 billing_company => $billing->{company},
961 billing_country => $billing->{country}->{name},
962 billing_department => $billing->{department},
963 billing_email => $import->{orderCustomer}->{email},
964 billing_fax => $billing->{fax},
965 billing_firstname => $billing->{firstName},
966 #billing_greeting => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
967 billing_lastname => $billing->{lastName},
968 billing_phone => $billing->{phone},
969 billing_street => $billing->{street},
970 billing_vat => $billing->{vatId},
971 billing_zipcode => $billing->{zipcode},
972 customer_city => $billing->{city},
973 customer_company => $billing->{company},
974 customer_country => $billing->{country}->{name},
975 customer_department => $billing->{department},
976 customer_email => $billing->{email},
977 customer_fax => $billing->{fax},
978 customer_firstname => $billing->{firstName},
979 #customer_greeting => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
980 customer_lastname => $billing->{lastName},
981 customer_phone => $billing->{phoneNumber},
982 customer_street => $billing->{street},
983 customer_vat => $billing->{vatId},
984 customer_zipcode => $billing->{zipcode},
985 # customer_newsletter => $customer}->{newsletter},
986 delivery_city => $shipto->{city},
987 delivery_company => $shipto->{company},
988 delivery_country => $shipto->{country}->{name},
989 delivery_department => $shipto->{department},
990 delivery_email => "",
991 delivery_fax => $shipto->{fax},
992 delivery_firstname => $shipto->{firstName},
993 #delivery_greeting => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
994 delivery_lastname => $shipto->{lastName},
995 delivery_phone => $shipto->{phone},
996 delivery_street => $shipto->{street},
997 delivery_vat => $shipto->{vatId},
998 delivery_zipcode => $shipto->{zipcode},
999 # host => $shop}->{hosts},
1000 netamount => $import->{amountNet},
1001 order_date => $orderdate,
1002 payment_description => $payment->{name},
1003 payment_id => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
1004 tax_included => $tax_included eq "brutto" ? 1 : 0,
1005 shop_ordernumber => $import->{orderNumber},
1006 shop_id => $shop_id,
1007 shop_trans_id => $import->{id},
1009 #remote_ip => $import->{remoteAddress},
1010 #sepa_account_holder => $import->{paymentIntances}->{accountHolder},
1011 #sepa_bic => $import->{paymentIntances}->{bic},
1012 #sepa_iban => $import->{paymentIntances}->{iban},
1013 #shipping_costs => $import->{invoiceShipping},
1014 #shipping_costs_net => $import->{invoiceShippingNet},
1015 #shop_c_billing_id => $import->{billing}->{customerId},
1016 #shop_c_billing_number => $import->{billing}->{number},
1017 #shop_c_delivery_id => $import->{shipping}->{id},
1018 #shop_customer_id => $import->{customerId},
1019 #shop_customer_number => $import->{billing}->{number},
1020 #shop_customer_comment => $import->{customerComment},
1023 my $shop_order = SL::DB::ShopOrder->new(%columns);
1029 return encode('UTF-8', $value // '');
1040 SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
1047 =head1 AVAILABLE METHODS
1051 =item C<get_one_order>
1053 =item C<get_new_orders>
1055 =item C<update_part>
1057 Updates all metadata for a shop part. See base class for a general description.
1058 Specific Implementation notes:
1061 =item Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
1063 =item Checks if longdescription should be taken from part or shop_part
1065 =item Checks if a language with the name 'Englisch' or template_code 'en'
1066 is available and sets the shopware6 'en-GB' locales for the product
1068 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
1070 The connecting key for shopware to kivi images is the image name.
1071 To get distinct entries the kivi partnumber is combined with the title (description)
1072 of the image. Therefore part1000_someTitlefromUser should be unique in
1074 All image data is simply send to shopware whether or not image data
1075 has been edited recently.
1076 If set_cover is set, the image with the position 1 will be used as
1077 the shopware cover image.
1078 If delete_orphaned ist set, all images related to the shopware product
1079 which are not also in kivitendo will be deleted.
1080 Shopware (6.4.x) takes care of deleting all the relations if the media
1081 entry for the image is deleted.
1082 More on media and Shopware6 can be found here:
1083 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
1089 =item C<get_article>
1091 =item C<get_categories>
1093 =item C<get_version>
1095 Tries to establish a connection and in a second step
1096 tries to get the server's version number.
1097 Returns a hashref with the data structure the Base class expects.
1099 =item C<set_orderstatus>
1101 =item C<init_connector>
1103 Inits the connection to the REST Server.
1104 Errors are collected in $self->{errors} and undef will be returned.
1105 If successful returns a REST::Client object for further communications.
1111 L<SL::ShopConnector::ALL>
1121 =item * Map all data to shop_order
1123 Missing fields are commented in the sub map_data_to_shoporder.
1124 Some items are SEPA debit info, IP adress, delivery costs etc
1125 Furthermore Shopware6 uses currency, country and locales information.
1128 #customer_newsletter => $customer}->{newsletter},
1129 #remote_ip => $import->{remoteAddress},
1130 #sepa_account_holder => $import->{paymentIntances}->{accountHolder},
1131 #sepa_bic => $import->{paymentIntances}->{bic},
1132 #sepa_iban => $import->{paymentIntances}->{iban},
1133 #shipping_costs => $import->{invoiceShipping},
1134 #shipping_costs_net => $import->{invoiceShippingNet},
1135 #shop_c_billing_id => $import->{billing}->{customerId},
1136 #shop_c_billing_number => $import->{billing}->{number},
1137 #shop_c_delivery_id => $import->{shipping}->{id},
1138 #shop_customer_id => $import->{customerId},
1139 #shop_customer_number => $import->{billing}->{number},
1140 #shop_customer_comment => $import->{customerComment},
1142 =item * Use shipping_costs_parts_id for additional shipping costs
1144 Currently dies if a shipping_costs_parts_id is set in the config
1146 =item * Payment Infos can be read from shopware but is not linked with kivi
1148 Unused data structures in sub map_data_to_shoporder => payment_ary
1150 =item * Delete orphaned images is new in this connector, but should be in a separate method
1152 =item * Fetch from last order number is ignored and should not be needed
1154 Fetch orders also sets the state of the order from open to process. The state setting
1155 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
1156 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
1157 and ignores any shopware order transition state.
1159 =item * Get one order and get new orders is basically the same except for the filter
1161 Right now the returning structure and the common parts of the filter are in two separate functions
1165 Many error messages are thrown, but at least the more common cases should be localized.
1167 =item * Multi language support
1169 By guessing the correct german name for the english language some translation for parts can
1170 also be synced. This should be more clear (language configuration for shops) and the order
1171 synchronisation should also handle this (longdescription is simply copied from part.notes)
1173 =item * Shopware6 Promotion Codes
1175 Shopware6 Promotion Codes with discounts for specific positions (called line items in Shopware)
1176 are in a single line item of the type 'promotion'. kivitendo uses a simple real number in the
1177 range of 0 .. 1 to add a discount for a line item.
1178 This implementation adds a percentual discount for the correct positions and does not process
1179 the discount line item afterwards. The original Shopware Promotion Code is also saved.
1185 Jan Büren jan@kivitendo.de