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