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) = @_;
704 croak "No order ID, should be in format [0-9a-f]{32}" unless $order_id =~ m/^[0-9a-f]{32}$/;
705 croak "NO valid transition value" unless $transition =~ m/(open|process|cancel|complete)/;
707 $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
708 my $response_code = $ret->responseCode();
709 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
716 my $protocol = $self->config->server =~ /(^https:\/\/|^http:\/\/)/ ? '' : $self->config->protocol . '://';
717 my $client = REST::Client->new(host => $protocol . $self->config->server);
719 $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
720 $client->addHeader('Content-Type', 'application/json');
721 $client->addHeader('charset', 'UTF-8');
722 $client->addHeader('Accept', 'application/json');
725 client_id => $self->config->login,
726 client_secret => $self->config->password,
727 grant_type => "client_credentials",
730 my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
732 unless (200 == $ret->responseCode()) {
733 $self->{errors} .= $ret->responseContent();
737 my $token = from_json($client->responseContent())->{access_token};
739 $self->{errors} .= "No Auth-Token received";
742 # persist refresh token
743 $client->addHeader('Authorization' => 'Bearer ' . $token);
747 sub import_data_to_shop_order {
748 my ($self, $import) = @_;
750 # failsafe checks for not yet implemented
751 die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
753 # no mapping unless we also have at least one shop order item ...
754 my $order_pos = delete $import->{lineItems};
755 croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
757 my $shop_order = $self->map_data_to_shoporder($import);
759 my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
761 my $id = $shop_order->id;
763 my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
765 my $active_price_source = $self->config->price_source;
767 foreach my $pos (@positions) {
769 my $price = $::form->round_amount($pos->{unitPrice}, 2); # unit
770 my %pos_columns = ( description => $pos->{product}->{name},
771 partnumber => $pos->{product}->{productNumber},
773 quantity => $pos->{quantity},
774 position => $position,
775 tax_rate => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
776 shop_trans_id => $pos->{id}, # pos id or shop_trans_id ? or dont care?
777 shop_order_id => $id,
778 active_price_source => $active_price_source,
780 my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
783 $shop_order->positions($position);
785 if ( $self->config->shipping_costs_parts_id ) {
786 die t8("Not yet implemented");
787 # TODO NOT YET Implemented nor tested, this is shopware5 code:
788 my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
789 my %shipping_pos = ( description => $import->{data}->{dispatch}->{name},
790 partnumber => $shipping_part->partnumber,
791 price => $import->{data}->{invoiceShipping},
793 position => $position,
795 shop_order_id => $id,
797 my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
798 $shipping_pos_insert->save;
801 my $customer = $shop_order->get_customer;
803 if (ref $customer eq 'SL::DB::Customer') {
804 $shop_order->kivi_customer_id($customer->id);
808 # update state in shopware before transaction ends
809 $self->set_orderstatus($shop_order->shop_trans_id, "process");
813 }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
814 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
817 sub map_data_to_shoporder {
818 my ($self, $import) = @_;
820 croak "Expect a hash with one order." unless ref $import eq 'HASH';
821 # we need one number and a order date, some total prices and one customer
822 croak "Does not look like a shopware6 order" unless $import->{orderNumber}
823 && $import->{orderDateTime}
824 && ref $import->{price} eq 'HASH'
825 && ref $import->{orderCustomer} eq 'HASH';
827 my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
828 die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
830 my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} } @{ $import->{addresses} } ];
831 my $shipto_ary = [ grep { $_->{id} == $shipto_id } @{ $import->{addresses} } ];
832 my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} } @{ $import->{paymentMethods} } ];
834 die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
835 $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
836 unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
838 my $billing = $billing_ary->[0];
839 my $shipto = $shipto_ary->[0];
840 # TODO payment info is not used at all
841 my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
843 # check mandatory fields from shopware
844 die t8("No billing city") unless $billing->{city};
845 die t8("No shipto city") unless $shipto->{city};
846 die t8("No customer email") unless $import->{orderCustomer}->{email};
849 my $parser = DateTime::Format::Strptime->new(pattern => '%Y-%m-%dT%H:%M:%S',
851 time_zone => 'local' );
854 $orderdate = $parser->parse_datetime($import->{orderDateTime});
855 } catch { die "Cannot parse Order Date" . $_ };
857 my $shop_id = $self->config->id;
858 my $tax_included = $self->config->pricetype;
860 # TODO copied from shopware5 connector
861 # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
862 my %payment_ids_methods = (
863 # shopware_paymentId => kivitendo_payment_id
865 my $default_payment = SL::DB::Manager::PaymentTerm->get_first();
866 my $default_payment_id = $default_payment ? $default_payment->id : undef;
871 amount => $import->{amountTotal},
872 billing_city => $billing->{city},
873 billing_company => $billing->{company},
874 billing_country => $billing->{country}->{name},
875 billing_department => $billing->{department},
876 billing_email => $import->{orderCustomer}->{email},
877 billing_fax => $billing->{fax},
878 billing_firstname => $billing->{firstName},
879 #billing_greeting => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
880 billing_lastname => $billing->{lastName},
881 billing_phone => $billing->{phone},
882 billing_street => $billing->{street},
883 billing_vat => $billing->{vatId},
884 billing_zipcode => $billing->{zipcode},
885 customer_city => $billing->{city},
886 customer_company => $billing->{company},
887 customer_country => $billing->{country}->{name},
888 customer_department => $billing->{department},
889 customer_email => $billing->{email},
890 customer_fax => $billing->{fax},
891 customer_firstname => $billing->{firstName},
892 #customer_greeting => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
893 customer_lastname => $billing->{lastName},
894 customer_phone => $billing->{phoneNumber},
895 customer_street => $billing->{street},
896 customer_vat => $billing->{vatId},
897 customer_zipcode => $billing->{zipcode},
898 # customer_newsletter => $customer}->{newsletter},
899 delivery_city => $shipto->{city},
900 delivery_company => $shipto->{company},
901 delivery_country => $shipto->{country}->{name},
902 delivery_department => $shipto->{department},
903 delivery_email => "",
904 delivery_fax => $shipto->{fax},
905 delivery_firstname => $shipto->{firstName},
906 #delivery_greeting => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
907 delivery_lastname => $shipto->{lastName},
908 delivery_phone => $shipto->{phone},
909 delivery_street => $shipto->{street},
910 delivery_vat => $shipto->{vatId},
911 delivery_zipcode => $shipto->{zipCode},
912 # host => $shop}->{hosts},
913 netamount => $import->{amountNet},
914 order_date => $orderdate,
915 payment_description => $payment->{name},
916 payment_id => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
917 tax_included => $tax_included eq "brutto" ? 1 : 0,
918 shop_ordernumber => $import->{orderNumber},
920 shop_trans_id => $import->{id},
922 #remote_ip => $import->{remoteAddress},
923 #sepa_account_holder => $import->{paymentIntances}->{accountHolder},
924 #sepa_bic => $import->{paymentIntances}->{bic},
925 #sepa_iban => $import->{paymentIntances}->{iban},
926 #shipping_costs => $import->{invoiceShipping},
927 #shipping_costs_net => $import->{invoiceShippingNet},
928 #shop_c_billing_id => $import->{billing}->{customerId},
929 #shop_c_billing_number => $import->{billing}->{number},
930 #shop_c_delivery_id => $import->{shipping}->{id},
931 #shop_customer_id => $import->{customerId},
932 #shop_customer_number => $import->{billing}->{number},
933 #shop_customer_comment => $import->{customerComment},
936 my $shop_order = SL::DB::ShopOrder->new(%columns);
942 return encode('UTF-8', $value // '');
953 SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
960 =head1 AVAILABLE METHODS
964 =item C<get_one_order>
966 =item C<get_new_orders>
970 Updates all metadata for a shop part. See base class for a general description.
971 Specific Implementation notes:
974 =item Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
976 =item Checks if longdescription should be taken from part or shop_part
978 =item Checks if a language with the name 'Englisch' or template_code 'en'
979 is available and sets the shopware6 'en-GB' locales for the product
981 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
983 The connecting key for shopware to kivi images is the image name.
984 To get distinct entries the kivi partnumber is combined with the title (description)
985 of the image. Therefore part1000_someTitlefromUser should be unique in
987 All image data is simply send to shopware whether or not image data
988 has been edited recently.
989 If set_cover is set, the image with the position 1 will be used as
990 the shopware cover image.
991 If delete_orphaned ist set, all images related to the shopware product
992 which are not also in kivitendo will be deleted.
993 Shopware (6.4.x) takes care of deleting all the relations if the media
994 entry for the image is deleted.
995 More on media and Shopware6 can be found here:
996 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
1002 =item C<get_article>
1004 =item C<get_categories>
1006 =item C<get_version>
1008 Tries to establish a connection and in a second step
1009 tries to get the server's version number.
1010 Returns a hashref with the data structure the Base class expects.
1012 =item C<set_orderstatus>
1014 =item C<init_connector>
1016 Inits the connection to the REST Server.
1017 Errors are collected in $self->{errors} and undef will be returned.
1018 If successful returns a REST::Client object for further communications.
1024 L<SL::ShopConnector::ALL>
1034 =item * Map all data to shop_order
1036 Missing fields are commented in the sub map_data_to_shoporder.
1037 Some items are SEPA debit info, IP adress, delivery costs etc
1038 Furthermore Shopware6 uses currency, country and locales information.
1041 #customer_newsletter => $customer}->{newsletter},
1042 #remote_ip => $import->{remoteAddress},
1043 #sepa_account_holder => $import->{paymentIntances}->{accountHolder},
1044 #sepa_bic => $import->{paymentIntances}->{bic},
1045 #sepa_iban => $import->{paymentIntances}->{iban},
1046 #shipping_costs => $import->{invoiceShipping},
1047 #shipping_costs_net => $import->{invoiceShippingNet},
1048 #shop_c_billing_id => $import->{billing}->{customerId},
1049 #shop_c_billing_number => $import->{billing}->{number},
1050 #shop_c_delivery_id => $import->{shipping}->{id},
1051 #shop_customer_id => $import->{customerId},
1052 #shop_customer_number => $import->{billing}->{number},
1053 #shop_customer_comment => $import->{customerComment},
1055 =item * Use shipping_costs_parts_id for additional shipping costs
1057 Currently dies if a shipping_costs_parts_id is set in the config
1059 =item * Payment Infos can be read from shopware but is not linked with kivi
1061 Unused data structures in sub map_data_to_shoporder => payment_ary
1063 =item * Delete orphaned images is new in this connector, but should be in a separate method
1065 =item * Fetch from last order number is ignored and should not be needed
1067 Fetch orders also sets the state of the order from open to process. The state setting
1068 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
1069 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
1070 and ignores any shopware order transition state.
1072 =item * Get one order and get new orders is basically the same except for the filter
1074 Right now the returning structure and the common parts of the filter are in two separate functions
1078 Many error messages are thrown, but at least the more common cases should be localized.
1080 =item * Multi language support
1082 By guessing the correct german name for the english language some translation for parts can
1083 also be synced. This should be more clear (language configuration for shops) and the order
1084 synchronisation should also handle this (longdescription is simply copied from part.notes)
1090 Jan Büren jan@kivitendo.de