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 die "Updating part with " . $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
186 # 1. get the correct tax for this product
196 $ret = $self->connector->POST('api/search/tax', to_json($tax_filter));
197 die "Search for Tax with rate: " . $part->partnumber . " failed: " . $ret->responseContent() unless (200 == $ret->responseCode());
199 $update_p->{taxId} = from_json($ret->responseContent())->{data}->[0]->{id};
200 } catch { die "Malformed JSON Data or Taxkey entry missing: $_ " . $ret->responseContent(); };
202 # 2. get the correct currency for this product
203 my $currency_filter = {
206 'value' => SL::DB::Default->get_default_currency,
212 $ret = $self->connector->POST('api/search/currency', to_json($currency_filter));
213 die "Search for Currency with ISO Code: " . SL::DB::Default->get_default_currency . " failed: "
214 . $ret->responseContent() unless (200 == $ret->responseCode());
217 $update_p->{price}->[0]->{currencyId} = from_json($ret->responseContent())->{data}->[0]->{id};
218 } catch { die "Malformed JSON Data or Currency ID entry missing: $_ " . $ret->responseContent(); };
220 # 3. add net and gross price and allow variants
221 $update_p->{price}->[0]->{gross} = $gross;
222 $update_p->{price}->[0]->{net} = $net;
223 $update_p->{price}->[0]->{linked} = \1; # link product variants
225 $ret = $self->connector->POST('api/product', to_json($update_p));
226 die "Create for Product " . $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
229 # if there are images try to sync this with the shop_part
231 $self->sync_all_images(shop_part => $shop_part, set_cover => 1, delete_orphaned => 1);
232 } catch { die "Could not sync images for Part " . $part->partnumber . " Reason: $_" };
234 # if there are categories try to sync this with the shop_part
236 $self->sync_all_categories(shop_part => $shop_part);
237 } catch { die "Could not sync Categories for Part " . $part->partnumber . " Reason: $_" };
239 return 1; # no invalid response code -> success
241 sub sync_all_categories {
242 my ($self, %params) = @_;
244 my $shop_part = delete $params{shop_part};
245 croak "Need a valid Shop Part for updating Images" unless ref($shop_part) eq 'SL::DB::ShopPart';
247 my $partnumber = $shop_part->part->partnumber;
248 die "Shop Part but no kivi Partnumber" unless $partnumber;
250 my ($ret, $response_code);
251 # 1 get uuid for product
252 my $product_filter = {
255 'value' => $partnumber,
257 'field' => 'productNumber'
262 $ret = $self->connector->POST('api/search/product', to_json($product_filter));
263 $response_code = $ret->responseCode();
264 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
265 my ($product_id, $category_tree);
267 $product_id = from_json($ret->responseContent())->{data}->[0]->{id};
268 $category_tree = from_json($ret->responseContent())->{data}->[0]->{categoryIds};
269 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
271 # if the part is connected to a category at all
272 if ($shop_part->shop_category) {
273 foreach my $row_cat (@{ $shop_part->shop_category }) {
274 $cat->{@{ $row_cat }[0]} = @{ $row_cat }[1];
278 foreach my $shopware_cat (@{ $category_tree }) {
279 if ($cat->{$shopware_cat}) {
280 # cat exists and no delete
281 delete $cat->{$shopware_cat};
284 # cat exists and delete
285 $ret = $self->connector->DELETE("api/product/$product_id/categories/$shopware_cat");
286 $response_code = $ret->responseCode();
287 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
289 # now add only new categories
291 $p->{id} = $product_id;
292 $p->{categories} = ();
293 foreach my $new_cat (keys %{ $cat }) {
294 push @{ $p->{categories} }, {id => $new_cat};
296 $ret = $self->connector->PATCH("api/product/$product_id", to_json($p));
297 $response_code = $ret->responseCode();
298 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
301 sub sync_all_images {
302 my ($self, %params) = @_;
304 $params{set_cover} //= 1;
305 $params{delete_orphaned} //= 0;
307 my $shop_part = delete $params{shop_part};
308 croak "Need a valid Shop Part for updating Images" unless ref($shop_part) eq 'SL::DB::ShopPart';
310 my $partnumber = $shop_part->part->partnumber;
311 die "Shop Part but no kivi Partnumber" unless $partnumber;
313 my @upload_img = $shop_part->get_images(want_binary => 1);
315 return unless (@upload_img); # there are no images, but delete wont work TODO extract to method
317 my ($ret, $response_code);
318 # 1. get part uuid and get media associations
319 # 2. create or update the media entry for the filename
320 # 2.1 if no media entry exists create one
322 # 2.2 create or update media_product and set position
323 # 3. optional set cover image
324 # 4. optional delete images in shopware which are not in kivi
326 # 1 get mediaid uuid for prodcut
327 my $product_filter = {
333 'value' => $partnumber,
335 'field' => 'productNumber'
340 $ret = $self->connector->POST('api/search/product', to_json($product_filter));
341 $response_code = $ret->responseCode();
342 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
343 my ($product_id, $media_data);
345 $product_id = from_json($ret->responseContent())->{data}->[0]->{id};
346 # $media_data = from_json($ret->responseContent())->{data}->[0]->{media};
347 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
349 # 2 iterate all kivi images and save distinct name for later sync
351 foreach my $img (@upload_img) {
352 die $::locale->text("Need a image title") unless $img->{description};
353 my $distinct_media_name = $partnumber . '_' . $img->{description};
354 $existing_images{$distinct_media_name} = 1;
355 my $image_filter = { 'filter' => [
357 'value' => $distinct_media_name,
359 'field' => 'fileName'
363 $ret = $self->connector->POST('api/search/media', to_json($image_filter));
364 $response_code = $ret->responseCode();
365 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
366 my $current_image_id; # maybe empty
368 $current_image_id = from_json($ret->responseContent())->{data}->[0]->{id};
369 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
371 # 2.1 no image with this title, create metadata for media and upload image
372 if (!$current_image_id) {
373 # not yet uploaded, create media entry
374 $ret = $self->connector->POST("/api/media?_response=true");
375 $response_code = $ret->responseCode();
376 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
378 $current_image_id = from_json($ret->responseContent())->{data}{id};
379 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
381 # 2.2 update the image data (current_image_id was found or created)
382 $ret = $self->connector->POST("/api/_action/media/$current_image_id/upload?fileName=$distinct_media_name&extension=$img->{extension}",
385 "Content-Type" => "image/$img->{extension}",
387 $response_code = $ret->responseCode();
388 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
390 # 2.3 check if a product media entry exists for this id
391 my $product_media_filter = {
394 'value' => $product_id,
396 'field' => 'productId'
399 'value' => $current_image_id,
405 $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
406 $response_code = $ret->responseCode();
407 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
408 my ($has_product_media, $product_media_id);
410 $has_product_media = from_json($ret->responseContent())->{total};
411 $product_media_id = from_json($ret->responseContent())->{data}->[0]->{id};
412 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
414 # 2.4 ... and either update or create the entry
415 # set shopware position to kivi position
417 $product_media->{position} = $img->{position}; # position may change
419 if ($has_product_media == 0) {
420 # 2.4.1 new entry. link product to media
421 $product_media->{productId} = $product_id;
422 $product_media->{mediaId} = $current_image_id;
423 $ret = $self->connector->POST('api/product-media', to_json($product_media));
424 } elsif ($has_product_media == 1 && $product_media_id) {
425 $ret = $self->connector->PATCH("api/product-media/$product_media_id", to_json($product_media));
427 die "Invalid state, please inform Shopware master admin at product-media filter: $product_media_filter";
429 $response_code = $ret->responseCode();
430 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
432 # 3. optional set image with position 1 as cover image
433 if ($params{set_cover}) {
434 # set cover if position == 1
435 my $product_media_filter = {
438 'value' => $product_id,
440 'field' => 'productId'
445 'field' => 'position'
450 $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
451 $response_code = $ret->responseCode();
452 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
455 $cover->{coverId} = from_json($ret->responseContent())->{data}->[0]->{id};
456 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
457 $ret = $self->connector->PATCH('api/product/' . $product_id, to_json($cover));
458 $response_code = $ret->responseCode();
459 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
461 # 4. optional delete orphaned images in shopware
462 if ($params{delete_orphaned}) {
463 # delete orphaned images
464 my $product_media_filter = {
467 'value' => $product_id,
469 'field' => 'productId'
471 $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
472 $response_code = $ret->responseCode();
473 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
476 $img_ary = from_json($ret->responseContent())->{data};
477 } catch { die "Malformed JSON Data: $_ " . $ret->responseContent(); };
479 if (scalar @{ $img_ary} > 0) { # maybe no images at all
481 $existing_img{$_->{media}->{fileName}}= $_->{media}->{id} for @{ $img_ary };
483 while (my ($name, $id) = each %existing_img) {
484 next if $existing_images{$name};
485 $ret = $self->connector->DELETE("api/media/$id");
486 $response_code = $ret->responseCode();
487 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
497 my $ret = $self->connector->POST('api/search/category');
498 my $response_code = $ret->responseCode();
500 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
504 $import = decode_json $ret->responseContent();
506 die "Malformed JSON Data: $_ " . $ret->responseContent();
509 my @daten = @{ $import->{data} };
510 my %categories = map { ($_->{id} => $_) } @daten;
514 my $parent = $categories{$_->{parentId}};
516 $parent->{children} ||= [];
517 push @{ $parent->{children} }, $_;
519 push @categories_tree, $_;
522 return \@categories_tree;
526 my ($self, $ordnumber) = @_;
528 croak t8("No Order Number") unless $ordnumber;
529 # set known params for the return structure
530 my %fetched_order = $self->get_fetched_order_structure;
531 my $assoc = $self->all_open_orders();
533 # overwrite filter for exactly one ordnumber
534 $assoc->{filter}->[0]->{value} = $ordnumber;
535 $assoc->{filter}->[0]->{type} = 'equals';
536 $assoc->{filter}->[0]->{field} = 'orderNumber';
538 # 1. fetch the order and import it as a kivi order
539 # 2. return the number of processed order (1)
540 my $one_order = $self->connector->POST('api/search/order', to_json($assoc));
542 # 1. check for bad request or connection problems
543 if ($one_order->responseCode() != 200) {
544 $fetched_order{error} = 1;
545 $fetched_order{message} = $one_order->responseCode() . ' ' . $one_order->responseContent();
546 return \%fetched_order;
549 # 1.1 parse json or exit
552 $content = from_json($one_order->responseContent());
554 $fetched_order{error} = 1;
555 $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
556 return \%fetched_order;
559 # 2. check if we found ONE order at all
560 my $total = $content->{total};
562 $fetched_order{number_of_orders} = 0;
563 return \%fetched_order;
564 } elsif ($total != 1) {
565 $fetched_order{error} = 1;
566 $fetched_order{message} = "More than one Order returned. Invalid State: $total";
567 return \%fetched_order;
570 # 3. there is one valid order, try to import this one
571 if ($self->import_data_to_shop_order($content->{data}->[0])) {
572 %fetched_order = (shop_description => $self->config->description, number_of_orders => 1);
574 $fetched_order{message} = "Error: $@";
575 $fetched_order{error} = 1;
577 return \%fetched_order;
583 my %fetched_order = $self->get_fetched_order_structure;
584 my $assoc = $self->all_open_orders();
586 # 1. fetch all open orders and try to import it as a kivi order
587 # 2. return the number of processed order $total
588 my $open_orders = $self->connector->POST('api/search/order', to_json($assoc));
590 # 1. check for bad request or connection problems
591 if ($open_orders->responseCode() != 200) {
592 $fetched_order{error} = 1;
593 $fetched_order{message} = $open_orders->responseCode() . ' ' . $open_orders->responseContent();
594 return \%fetched_order;
597 # 1.1 parse json or exit
600 $content = from_json($open_orders->responseContent());
602 $fetched_order{error} = 1;
603 $fetched_order{message} = "Malformed JSON Data: $_ " . $open_orders->responseContent();
604 return \%fetched_order;
607 # 2. check if we found one or more order at all
608 my $total = $content->{total};
610 $fetched_order{number_of_orders} = 0;
611 return \%fetched_order;
612 } elsif (!$total || !($total > 0)) {
613 $fetched_order{error} = 1;
614 $fetched_order{message} = "Undefined value for total orders returned. Invalid State: $total";
615 return \%fetched_order;
618 # 3. there are open orders. try to import one by one
619 $fetched_order{number_of_orders} = 0;
620 foreach my $open_order (@{ $content->{data} }) {
621 if ($self->import_data_to_shop_order($open_order)) {
622 $fetched_order{number_of_orders}++;
624 $fetched_order{message} .= "Error at importing order with running number:"
625 . $fetched_order{number_of_orders}+1 . ": $@ \n";
626 $fetched_order{error} = 1;
629 return \%fetched_order;
633 my ($self, $partnumber) = @_;
635 $partnumber = $::form->escape($partnumber);
636 my $product_filter = {
639 'value' => $partnumber,
641 'field' => 'productNumber'
645 my $ret = $self->connector->POST('api/search/product', to_json($product_filter));
647 my $response_code = $ret->responseCode();
648 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
652 $data_json = decode_json $ret->responseContent();
654 die "Malformed JSON Data: $_ " . $ret->responseContent();
657 # maybe no product was found ...
658 return undef unless scalar @{ $data_json->{data} } > 0;
659 # caller wants this structure:
660 # $stock_onlineshop = $shop_article->{data}->{mainDetail}->{inStock};
661 # $active_online = $shop_article->{data}->{active};
663 $data->{data}->{mainDetail}->{inStock} = $data_json->{data}->[0]->{stock};
664 $data->{data}->{active} = $data_json->{data}->[0]->{active};
671 my $return = {}; # return for caller
672 my $ret = {}; # internal return
674 # 1. check if we can connect at all
675 # 2. request version number
677 $ret = $self->connector;
678 if (200 != $ret->responseCode()) {
679 $return->{success} = 0;
680 $return->{data}->{version} = $self->{errors}; # whatever init puts in errors
684 $ret = $self->connector->GET('api/_info/version');
685 if (200 == $ret->responseCode()) {
686 my $version = from_json($self->connector->responseContent())->{version};
687 $return->{success} = 1;
688 $return->{data}->{version} = $version;
690 $return->{success} = 0;
691 $return->{data}->{version} = $ret->responseContent(); # Whatever REST Interface says
697 sub set_orderstatus {
698 my ($self, $order_id, $transition) = @_;
700 croak "No order ID, should be in format [0-9a-f]{32}" unless $order_id =~ m/^[0-9a-f]{32}$/;
701 croak "NO valid transition value" unless $transition =~ m/(open|process|cancel|complete)/;
703 $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
704 my $response_code = $ret->responseCode();
705 die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
712 my $client = REST::Client->new(host => $self->config->server);
713 $client->addHeader('Content-Type', 'application/json');
714 $client->addHeader('charset', 'UTF-8');
715 $client->addHeader('Accept', 'application/json');
718 client_id => $self->config->login,
719 client_secret => $self->config->password,
720 grant_type => "client_credentials",
723 my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
725 unless (200 == $ret->responseCode()) {
726 $self->{errors} .= $ret->responseContent();
730 my $token = from_json($client->responseContent())->{access_token};
732 $self->{errors} .= "No Auth-Token received";
735 # persist refresh token
736 $client->addHeader('Authorization' => 'Bearer ' . $token);
740 sub import_data_to_shop_order {
741 my ($self, $import) = @_;
743 # failsafe checks for not yet implemented
744 die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
746 # no mapping unless we also have at least one shop order item ...
747 my $order_pos = delete $import->{lineItems};
748 croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
750 my $shop_order = $self->map_data_to_shoporder($import);
752 my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
754 my $id = $shop_order->id;
756 my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
758 my $active_price_source = $self->config->price_source;
760 foreach my $pos (@positions) {
762 my $price = $::form->round_amount($pos->{unitPrice}, 2); # unit
763 my %pos_columns = ( description => $pos->{product}->{description},
764 partnumber => $pos->{product}->{productNumber},
766 quantity => $pos->{quantity},
767 position => $position,
768 tax_rate => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
769 shop_trans_id => $pos->{id}, # pos id or shop_trans_id ? or dont care?
770 shop_order_id => $id,
771 active_price_source => $active_price_source,
773 my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
776 $shop_order->positions($position);
778 if ( $self->config->shipping_costs_parts_id ) {
779 die t8("Not yet implemented");
780 # TODO NOT YET Implemented nor tested, this is shopware5 code:
781 my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
782 my %shipping_pos = ( description => $import->{data}->{dispatch}->{name},
783 partnumber => $shipping_part->partnumber,
784 price => $import->{data}->{invoiceShipping},
786 position => $position,
788 shop_order_id => $id,
790 my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
791 $shipping_pos_insert->save;
794 my $customer = $shop_order->get_customer;
796 if (ref $customer eq 'SL::DB::Customer') {
797 $shop_order->kivi_customer_id($customer->id);
801 # update state in shopware before transaction ends
802 $self->set_orderstatus($shop_order->shop_trans_id, "process");
806 }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
807 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
810 sub map_data_to_shoporder {
811 my ($self, $import) = @_;
813 croak "Expect a hash with one order." unless ref $import eq 'HASH';
814 # we need one number and a order date, some total prices and one customer
815 croak "Does not look like a shopware6 order" unless $import->{orderNumber}
816 && $import->{orderDateTime}
817 && ref $import->{price} eq 'HASH'
818 && ref $import->{orderCustomer} eq 'HASH';
820 my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
821 die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
823 my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} } @{ $import->{addresses} } ];
824 my $shipto_ary = [ grep { $_->{id} == $shipto_id } @{ $import->{addresses} } ];
825 my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} } @{ $import->{paymentMethods} } ];
827 die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
828 $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
829 unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
831 my $billing = $billing_ary->[0];
832 my $shipto = $shipto_ary->[0];
833 # TODO payment info is not used at all
834 my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
836 # check mandatory fields from shopware
837 die t8("No billing city") unless $billing->{city};
838 die t8("No shipto city") unless $shipto->{city};
839 die t8("No customer email") unless $import->{orderCustomer}->{email};
842 my $parser = DateTime::Format::Strptime->new(pattern => '%Y-%m-%dT%H:%M:%S',
844 time_zone => 'local' );
847 $orderdate = $parser->parse_datetime($import->{orderDateTime});
848 } catch { die "Cannot parse Order Date" . $_ };
850 my $shop_id = $self->config->id;
851 my $tax_included = $self->config->pricetype;
853 # TODO copied from shopware5 connector
854 # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
855 my %payment_ids_methods = (
856 # shopware_paymentId => kivitendo_payment_id
858 my $default_payment = SL::DB::Manager::PaymentTerm->get_first();
859 my $default_payment_id = $default_payment ? $default_payment->id : undef;
864 amount => $import->{amountTotal},
865 billing_city => $billing->{city},
866 billing_company => $billing->{company},
867 billing_country => $billing->{country}->{name},
868 billing_department => $billing->{department},
869 billing_email => $import->{orderCustomer}->{email},
870 billing_fax => $billing->{fax},
871 billing_firstname => $billing->{firstName},
872 #billing_greeting => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
873 billing_lastname => $billing->{lastName},
874 billing_phone => $billing->{phone},
875 billing_street => $billing->{street},
876 billing_vat => $billing->{vatId},
877 billing_zipcode => $billing->{zipcode},
878 customer_city => $billing->{city},
879 customer_company => $billing->{company},
880 customer_country => $billing->{country}->{name},
881 customer_department => $billing->{department},
882 customer_email => $billing->{email},
883 customer_fax => $billing->{fax},
884 customer_firstname => $billing->{firstName},
885 #customer_greeting => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
886 customer_lastname => $billing->{lastName},
887 customer_phone => $billing->{phoneNumber},
888 customer_street => $billing->{street},
889 customer_vat => $billing->{vatId},
890 customer_zipcode => $billing->{zipcode},
891 # customer_newsletter => $customer}->{newsletter},
892 delivery_city => $shipto->{city},
893 delivery_company => $shipto->{company},
894 delivery_country => $shipto->{country}->{name},
895 delivery_department => $shipto->{department},
896 delivery_email => "",
897 delivery_fax => $shipto->{fax},
898 delivery_firstname => $shipto->{firstName},
899 #delivery_greeting => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
900 delivery_lastname => $shipto->{lastName},
901 delivery_phone => $shipto->{phone},
902 delivery_street => $shipto->{street},
903 delivery_vat => $shipto->{vatId},
904 delivery_zipcode => $shipto->{zipCode},
905 # host => $shop}->{hosts},
906 netamount => $import->{amountNet},
907 order_date => $orderdate,
908 payment_description => $payment->{name},
909 payment_id => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
910 tax_included => $tax_included eq "brutto" ? 1 : 0,
911 shop_ordernumber => $import->{orderNumber},
913 shop_trans_id => $import->{id},
915 #remote_ip => $import->{remoteAddress},
916 #sepa_account_holder => $import->{paymentIntances}->{accountHolder},
917 #sepa_bic => $import->{paymentIntances}->{bic},
918 #sepa_iban => $import->{paymentIntances}->{iban},
919 #shipping_costs => $import->{invoiceShipping},
920 #shipping_costs_net => $import->{invoiceShippingNet},
921 #shop_c_billing_id => $import->{billing}->{customerId},
922 #shop_c_billing_number => $import->{billing}->{number},
923 #shop_c_delivery_id => $import->{shipping}->{id},
924 #shop_customer_id => $import->{customerId},
925 #shop_customer_number => $import->{billing}->{number},
926 #shop_customer_comment => $import->{customerComment},
929 my $shop_order = SL::DB::ShopOrder->new(%columns);
935 return encode('UTF-8', $value // '');
946 SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
953 =head1 AVAILABLE METHODS
957 =item C<get_one_order>
959 =item C<get_new_orders>
963 Updates all metadata for a shop part. See base class for a general description.
964 Specific Implementation notes:
967 =item * Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
969 =item * Checks if longdescription should be taken from part or shop_part
971 =item * Checks if a language with the name 'Englisch' or template_code 'en'
972 is available and sets the shopware6 'en-GB' locales for the product
976 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
978 The connecting key for shopware to kivi images is the image name.
979 To get distinct entries the kivi partnumber is combined with the title (description)
980 of the image. Therefore part1000_someTitlefromUser should be unique in
982 All image data is simply send to shopware whether or not image data
983 has been edited recently.
984 If set_cover is set, the image with the position 1 will be used as
985 the shopware cover image.
986 If delete_orphaned ist set, all images related to the shopware product
987 which are not also in kivitendo will be deleted.
988 Shopware (6.4.x) takes care of deleting all the relations if the media
989 entry for the image is deleted.
990 More on media and Shopware6 can be found here:
991 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
996 =item C<get_categories>
1000 Tries to establish a connection and in a second step
1001 tries to get the server's version number.
1002 Returns a hashref with the data structure the Base class expects.
1004 =item C<set_orderstatus>
1006 =item C<init_connector>
1008 Inits the connection to the REST Server.
1009 Errors are collected in $self->{errors} and undef will be returned.
1010 If successful returns a REST::Client object for further communications.
1016 L<SL::ShopConnector::ALL>
1026 =item * Map all data to shop_order
1028 Missing fields are commented in the sub map_data_to_shoporder.
1029 Some items are SEPA debit info, IP adress, delivery costs etc
1030 Furthermore Shopware6 uses currency, country and locales information.
1033 #customer_newsletter => $customer}->{newsletter},
1034 #remote_ip => $import->{remoteAddress},
1035 #sepa_account_holder => $import->{paymentIntances}->{accountHolder},
1036 #sepa_bic => $import->{paymentIntances}->{bic},
1037 #sepa_iban => $import->{paymentIntances}->{iban},
1038 #shipping_costs => $import->{invoiceShipping},
1039 #shipping_costs_net => $import->{invoiceShippingNet},
1040 #shop_c_billing_id => $import->{billing}->{customerId},
1041 #shop_c_billing_number => $import->{billing}->{number},
1042 #shop_c_delivery_id => $import->{shipping}->{id},
1043 #shop_customer_id => $import->{customerId},
1044 #shop_customer_number => $import->{billing}->{number},
1045 #shop_customer_comment => $import->{customerComment},
1047 =item * Use shipping_costs_parts_id for additional shipping costs
1049 Currently dies if a shipping_costs_parts_id is set in the config
1051 =item * Payment Infos can be read from shopware but is not linked with kivi
1053 Unused data structures in sub map_data_to_shoporder => payment_ary
1055 =item * Delete orphaned images is new in this connector, but should be in a separate method
1057 =item * Fetch from last order number is ignored and should not be needed
1059 Fetch orders also sets the state of the order from open to process. The state setting
1060 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
1061 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
1062 and ignores any shopware order transition state.
1064 =item * Get one order and get new orders is basically the same except for the filter
1066 Right now the returning structure and the common parts of the filter are in two separate functions
1070 Many error messages are thrown, but at least the more common cases should be localized.
1076 Jan Büren jan@kivitendo.de