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