Shopware6: Optionalen Proxy erlauben und Protokoll http oder https initialisieren
[kivitendo-erp.git] / SL / ShopConnector / Shopware6.pm
1 package SL::ShopConnector::Shopware6;
2
3 use strict;
4
5 use parent qw(SL::ShopConnector::Base);
6
7 use Carp;
8 use Encode qw(encode);
9 use List::Util qw(first);
10 use REST::Client;
11 use Try::Tiny;
12
13 use SL::JSON;
14 use SL::Helper::Flash;
15 use SL::Locale::String qw(t8);
16
17 use Rose::Object::MakeMethods::Generic (
18   'scalar --get_set_init' => [ qw(connector) ],
19 );
20
21 sub all_open_orders {
22   my ($self) = @_;
23
24   my $assoc = {
25               'associations' => {
26                 'deliveries'   => {
27                   'associations' => {
28                     'shippingMethod' => [],
29                       'shippingOrderAddress' => {
30                         'associations' => {
31                                             'salutation'   => [],
32                                             'country'      => [],
33                                             'countryState' => []
34                                           }
35                                                 }
36                                      }
37                                    }, # end deliveries
38                 'language' => [],
39                 'orderCustomer' => [],
40                 'addresses' => {
41                   'associations' => {
42                                       'salutation'   => [],
43                                       'countryState' => [],
44                                       'country'      => []
45                                     }
46                                 },
47                 'tags' => [],
48                 'lineItems' => {
49                   'associations' => {
50                     'product' => {
51                       'associations' => {
52                                           'tax' => []
53                                         }
54                                  }
55                                     }
56                                 }, # end line items
57                 'salesChannel' => [],
58                   'documents' => {          # currently not used
59                     'associations' => {
60                       'documentType' => []
61                                       }
62                                  },
63                 'transactions' => {
64                   'associations' => {
65                     'paymentMethod' => []
66                                     }
67                                   },
68                 'currency' => []
69             }, # end associations
70          'limit' => $self->config->orders_to_fetch ? $self->config->orders_to_fetch : undef,
71         # 'page' => 1,
72      'aggregations' => [
73                             {
74                               'field'      => 'billingAddressId',
75                               'definition' => 'order_address',
76                               'name'       => 'BillingAddress',
77                               'type'       => 'entity'
78                             }
79                           ],
80         'filter' => [
81                      {
82                         'value' => 'open', # open or completed (mind the past)
83                         'type' => 'equals',
84                         'field' => 'order.stateMachineState.technicalName'
85                       }
86                     ],
87         'total-count-mode' => 0
88       };
89   return $assoc;
90 }
91
92 # used for get_new_orders and get_one_order
93 sub get_fetched_order_structure {
94   my ($self) = @_;
95   # set known params for the return structure
96   my %fetched_order  = (
97       shop_id          => $self->config->id,
98       shop_description => $self->config->description,
99       message          => '',
100       error            => '',
101       number_of_orders => 0,
102     );
103   return %fetched_order;
104 }
105
106 sub update_part {
107   my ($self, $shop_part, $todo) = @_;
108
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)/;
112
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';
115
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};
119
120   # simple calc for both cases, always give sw6 the calculated gross price
121   my ($net, $gross);
122   if ($self->config->pricetype eq 'brutto') {
123     $gross = $price;
124     $net   = $price / (1 + $taxrate/100);
125   } elsif ($self->config->pricetype eq 'netto') {
126     $net   = $price;
127     $gross = $price * (1 + $taxrate/100);
128   } else { die "Invalid state for price type"; }
129
130   my $update_p;
131   $update_p->{productNumber} = $part->partnumber;
132   $update_p->{name}          = _u8($part->description);
133   $update_p->{description}   =   $shop_part->shop->use_part_longdescription
134                                ? _u8($part->notes)
135                                : _u8($shop_part->shop_description);
136
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' } ],
140                                                     ]);
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});
147   }
148
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)/);
154
155   # 1. check if there is already a product
156   my $product_filter = {
157           'filter' => [
158                         {
159                           'value' => $part->partnumber,
160                           'type'  => 'equals',
161                           'field' => 'productNumber'
162                         }
163                       ]
164     };
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;
168
169   my $one_d; # maybe empty
170   try {
171     $one_d = from_json($ret->responseContent())->{data}->[0];
172   } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
173   # edit or create if not found
174   if ($one_d->{id}) {
175     #update
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;
180     }
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());
184   } else {
185     # create part
186     # 1. get the correct tax for this product
187     my $tax_filter = {
188           'filter' => [
189                         {
190                           'value' => $taxrate,
191                           'type' => 'equals',
192                           'field' => 'taxRate'
193                         }
194                       ]
195         };
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());
198     try {
199       $update_p->{taxId} = from_json($ret->responseContent())->{data}->[0]->{id};
200     } catch { die "Malformed JSON Data or Taxkey entry missing: $_ " . $ret->responseContent();  };
201
202     # 2. get the correct currency for this product
203     my $currency_filter = {
204         'filter' => [
205                       {
206                         'value' => SL::DB::Default->get_default_currency,
207                         'type' => 'equals',
208                         'field' => 'isoCode'
209                       }
210                     ]
211       };
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());
215
216     try {
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();  };
219
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
224
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());
227   }
228
229   # if there are images try to sync this with the shop_part
230   try {
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: $_" };
233
234   # if there are categories try to sync this with the shop_part
235   try {
236     $self->sync_all_categories(shop_part => $shop_part);
237   } catch { die "Could not sync Categories for Part " . $part->partnumber . " Reason: $_" };
238
239   return 1; # no invalid response code -> success
240 }
241 sub sync_all_categories {
242   my ($self, %params) = @_;
243
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';
246
247   my $partnumber = $shop_part->part->partnumber;
248   die "Shop Part but no kivi Partnumber" unless $partnumber;
249
250   my ($ret, $response_code);
251   # 1 get  uuid for product
252   my $product_filter = {
253           'filter' => [
254                         {
255                           'value' => $partnumber,
256                           'type'  => 'equals',
257                           'field' => 'productNumber'
258                         }
259                       ]
260     };
261
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);
266   try {
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();  };
270   my $cat;
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];
275     }
276   }
277   # delete
278   foreach my $shopware_cat (@{ $category_tree }) {
279     if ($cat->{$shopware_cat}) {
280       # cat exists and no delete
281       delete $cat->{$shopware_cat};
282       next;
283     }
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;
288   }
289   # now add only new categories
290   my $p;
291   $p->{id}  = $product_id;
292   $p->{categories} = ();
293   foreach my $new_cat (keys %{ $cat }) {
294     push @{ $p->{categories} }, {id => $new_cat};
295   }
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;
299 }
300
301 sub sync_all_images {
302   my ($self, %params) = @_;
303
304   $params{set_cover}       //= 1;
305   $params{delete_orphaned} //= 0;
306
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';
309
310   my $partnumber = $shop_part->part->partnumber;
311   die "Shop Part but no kivi Partnumber" unless $partnumber;
312
313   my @upload_img  = $shop_part->get_images(want_binary => 1);
314
315   return unless (@upload_img); # there are no images, but delete wont work TODO extract to method
316
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
321   # 2.2 update file
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
325
326   # 1 get mediaid uuid for prodcut
327   my $product_filter = {
328               'associations' => {
329                 'media'   => []
330               },
331           'filter' => [
332                         {
333                           'value' => $partnumber,
334                           'type'  => 'equals',
335                           'field' => 'productNumber'
336                         }
337                       ]
338     };
339
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);
344   try {
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();  };
348
349   # 2 iterate all kivi images and save distinct name for later sync
350   my %existing_images;
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' => [
356                           {
357                             'value' => $distinct_media_name,
358                             'type'  => 'equals',
359                             'field' => 'fileName'
360                           }
361                         ]
362                       };
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
367     try {
368       $current_image_id = from_json($ret->responseContent())->{data}->[0]->{id};
369     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
370
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;
377       try {
378         $current_image_id = from_json($ret->responseContent())->{data}{id};
379       } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
380     }
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}",
383                                     $img->{link},
384                                    {
385                                     "Content-Type"  => "image/$img->{extension}",
386                                    });
387     $response_code = $ret->responseCode();
388     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
389
390     # 2.3 check if a product media entry exists for this id
391     my $product_media_filter = {
392               'filter' => [
393                         {
394                           'value' => $product_id,
395                           'type' => 'equals',
396                           'field' => 'productId'
397                         },
398                         {
399                           'value' => $current_image_id,
400                           'type' => 'equals',
401                           'field' => 'mediaId'
402                         },
403                       ]
404         };
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);
409     try {
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();  };
413
414     # 2.4 ... and either update or create the entry
415     #     set shopware position to kivi position
416     my $product_media;
417     $product_media->{position} = $img->{position}; # position may change
418
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));
426     } else {
427       die "Invalid state, please inform Shopware master admin at product-media filter: $product_media_filter";
428     }
429     $response_code = $ret->responseCode();
430     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
431   }
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 = {
436               'filter' => [
437                         {
438                           'value' => $product_id,
439                           'type' => 'equals',
440                           'field' => 'productId'
441                         },
442                         {
443                           'value' => '1',
444                           'type' => 'equals',
445                           'field' => 'position'
446                         },
447                           ]
448                              };
449
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;
453     my $cover;
454     try {
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;
460   }
461   # 4. optional delete orphaned images in shopware
462   if ($params{delete_orphaned}) {
463     # delete orphaned images
464     my $product_media_filter = {
465               'filter' => [
466                         {
467                           'value' => $product_id,
468                           'type' => 'equals',
469                           'field' => 'productId'
470                         }, ] };
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;
474     my $img_ary;
475     try {
476       $img_ary = from_json($ret->responseContent())->{data};
477     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
478
479     if (scalar @{ $img_ary} > 0) { # maybe no images at all
480       my %existing_img;
481       $existing_img{$_->{media}->{fileName}}= $_->{media}->{id} for @{ $img_ary };
482
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;
488       }
489     }
490   }
491   return;
492 }
493
494 sub get_categories {
495   my ($self) = @_;
496
497   my $ret           = $self->connector->POST('api/search/category');
498   my $response_code = $ret->responseCode();
499
500   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
501
502   my $import;
503   try {
504     $import = decode_json $ret->responseContent();
505   } catch {
506     die "Malformed JSON Data: $_ " . $ret->responseContent();
507   };
508
509   my @daten      = @{ $import->{data} };
510   my %categories = map { ($_->{id} => $_) } @daten;
511
512   my @categories_tree;
513   for (@daten) {
514     my $parent = $categories{$_->{parentId}};
515     if ($parent) {
516       $parent->{children} ||= [];
517       push @{ $parent->{children} }, $_;
518     } else {
519       push @categories_tree, $_;
520     }
521   }
522   return \@categories_tree;
523 }
524
525 sub get_one_order  {
526   my ($self, $ordnumber) = @_;
527
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();
532
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';
537
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));
541
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;
547   }
548
549   # 1.1 parse json or exit
550   my $content;
551   try {
552     $content = from_json($one_order->responseContent());
553   } catch {
554     $fetched_order{error}   = 1;
555     $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
556     return \%fetched_order;
557   };
558
559   # 2. check if we found ONE order at all
560   my $total = $content->{total};
561   if ($total == 0) {
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;
568   }
569
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);
573   } else {
574     $fetched_order{message} = "Error: $@";
575     $fetched_order{error}   = 1;
576   }
577   return \%fetched_order;
578 }
579
580 sub get_new_orders {
581   my ($self) = @_;
582
583   my %fetched_order  = $self->get_fetched_order_structure;
584   my $assoc          = $self->all_open_orders();
585
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));
589
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;
595   }
596
597   # 1.1 parse json or exit
598   my $content;
599   try {
600     $content = from_json($open_orders->responseContent());
601   } catch {
602     $fetched_order{error}   = 1;
603     $fetched_order{message} = "Malformed JSON Data: $_ " . $open_orders->responseContent();
604     return \%fetched_order;
605   };
606
607   # 2. check if we found one or more order at all
608   my $total = $content->{total};
609   if ($total == 0) {
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;
616   }
617
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}++;
623     } else {
624       $fetched_order{message} .= "Error at importing order with running number:"
625                                   . $fetched_order{number_of_orders}+1 . ": $@ \n";
626       $fetched_order{error}    = 1;
627     }
628   }
629   return \%fetched_order;
630 }
631
632 sub get_article {
633   my ($self, $partnumber) = @_;
634
635   $partnumber   = $::form->escape($partnumber);
636   my $product_filter = {
637               'filter' => [
638                             {
639                               'value' => $partnumber,
640                               'type' => 'equals',
641                               'field' => 'productNumber'
642                             }
643                           ]
644                        };
645   my $ret = $self->connector->POST('api/search/product', to_json($product_filter));
646
647   my $response_code = $ret->responseCode();
648   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
649
650   my $data_json;
651   try {
652     $data_json = decode_json $ret->responseContent();
653   } catch {
654     die "Malformed JSON Data: $_ " . $ret->responseContent();
655   };
656
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};
662   my $data;
663   $data->{data}->{mainDetail}->{inStock} = $data_json->{data}->[0]->{stock};
664   $data->{data}->{active}                = $data_json->{data}->[0]->{active};
665   return $data;
666 }
667
668 sub get_version {
669   my ($self) = @_;
670
671   my $return  = {}; # return for caller
672   my $ret     = {}; # internal return
673
674   #  1. check if we can connect at all
675   #  2. request version number
676
677   $ret = $self->connector;
678   if (200 != $ret->responseCode()) {
679     $return->{success}         = 0;
680     $return->{data}->{version} = $self->{errors}; # whatever init puts in errors
681     return $return;
682   }
683
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;
689   } else {
690     $return->{success}         = 0;
691     $return->{data}->{version} = $ret->responseContent(); # Whatever REST Interface says
692   }
693
694   return $return;
695 }
696
697 sub set_orderstatus {
698   my ($self, $order_id, $transition) = @_;
699
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)/;
702   my $ret;
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';
706
707 }
708
709 sub init_connector {
710   my ($self) = @_;
711
712   my $client = REST::Client->new(host => $self->config->server);
713
714   $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
715
716   $client->addHeader('Content-Type', 'application/json');
717   $client->addHeader('charset',      'UTF-8');
718   $client->addHeader('Accept',       'application/json');
719
720   my %auth_req = (
721                    client_id     => $self->config->login,
722                    client_secret => $self->config->password,
723                    grant_type    => "client_credentials",
724                  );
725
726   my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
727
728   unless (200 == $ret->responseCode()) {
729     $self->{errors} .= $ret->responseContent();
730     return;
731   }
732
733   my $token = from_json($client->responseContent())->{access_token};
734   unless ($token) {
735     $self->{errors} .= "No Auth-Token received";
736     return;
737   }
738   # persist refresh token
739   $client->addHeader('Authorization' => 'Bearer ' . $token);
740   return $client;
741 }
742
743 sub import_data_to_shop_order {
744   my ($self, $import) = @_;
745
746   # failsafe checks for not yet implemented
747   die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
748
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';
752
753   my $shop_order = $self->map_data_to_shoporder($import);
754
755   my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
756     $shop_order->save;
757     my $id = $shop_order->id;
758
759     my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
760     my $position = 0;
761     my $active_price_source = $self->config->price_source;
762     #Mapping Positions
763     foreach my $pos (@positions) {
764       $position++;
765       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
766       my %pos_columns = ( description          => $pos->{product}->{description},
767                           partnumber           => $pos->{product}->{productNumber},
768                           price                => $price,
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,
775                         );
776       my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
777       $pos_insert->save;
778     }
779     $shop_order->positions($position);
780
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},
788                            quantity       => 1,
789                            position       => $position,
790                            shop_trans_id  => 0,
791                            shop_order_id  => $id,
792                          );
793       my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
794       $shipping_pos_insert->save;
795     }
796
797     my $customer = $shop_order->get_customer;
798
799     if (ref $customer eq 'SL::DB::Customer') {
800       $shop_order->kivi_customer_id($customer->id);
801     }
802     $shop_order->save;
803
804     # update state in shopware before transaction ends
805     $self->set_orderstatus($shop_order->shop_trans_id, "process");
806
807     1;
808
809   }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
810                 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
811 }
812
813 sub map_data_to_shoporder {
814   my ($self, $import) = @_;
815
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';
822
823   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
824   die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
825
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} } ];
829
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;
833
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;
838
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};
843
844   # extract order date
845   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
846                                                locale    => 'de_DE',
847                                                time_zone => 'local'             );
848   my $orderdate;
849   try {
850     $orderdate = $parser->parse_datetime($import->{orderDateTime});
851   } catch { die "Cannot parse Order Date" . $_ };
852
853   my $shop_id      = $self->config->id;
854   my $tax_included = $self->config->pricetype;
855
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
860   );
861   my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
862   my $default_payment_id = $default_payment ? $default_payment->id : undef;
863   #
864
865
866   my %columns = (
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},
915     shop_id                 => $shop_id,
916     shop_trans_id           => $import->{id},
917     # TODO map these:
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},
930   );
931
932   my $shop_order = SL::DB::ShopOrder->new(%columns);
933   return $shop_order;
934 }
935
936 sub _u8 {
937   my ($value) = @_;
938   return encode('UTF-8', $value // '');
939 }
940
941 1;
942
943 __END__
944
945 =encoding utf-8
946
947 =head1 NAME
948
949   SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
950
951 =head1 SYNOPSIS
952
953
954 =head1 DESCRIPTION
955
956 =head1 AVAILABLE METHODS
957
958 =over 4
959
960 =item C<get_one_order>
961
962 =item C<get_new_orders>
963
964 =item C<update_part>
965
966 Updates all metadata for a shop part. See base class for a general description.
967 Specific Implementation notes:
968 =over 4
969
970 =item * Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
971
972 =item * Checks if longdescription should be taken from part or shop_part
973
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
976
977 =back
978
979 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
980
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
984 Shopware.
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
995
996
997 =item C<get_article>
998
999 =item C<get_categories>
1000
1001 =item C<get_version>
1002
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.
1006
1007 =item C<set_orderstatus>
1008
1009 =item C<init_connector>
1010
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.
1014
1015 =back
1016
1017 =head1 SEE ALSO
1018
1019 L<SL::ShopConnector::ALL>
1020
1021 =head1 BUGS
1022
1023 None yet. :)
1024
1025 =head1 TODOS
1026
1027 =over 4
1028
1029 =item * Map all data to shop_order
1030
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.
1034 Detailed list:
1035
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},
1049
1050 =item * Use shipping_costs_parts_id for additional shipping costs
1051
1052 Currently dies if a shipping_costs_parts_id is set in the config
1053
1054 =item * Payment Infos can be read from shopware but is not linked with kivi
1055
1056 Unused data structures in sub map_data_to_shoporder => payment_ary
1057
1058 =item * Delete orphaned images is new in this connector, but should be in a separate method
1059
1060 =item * Fetch from last order number is ignored and should not be needed
1061
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.
1066
1067 =item * Get one order and get new orders is basically the same except for the filter
1068
1069 Right now the returning structure and the common parts of the filter are in two separate functions
1070
1071 =item * Locales!
1072
1073 Many error messages are thrown, but at least the more common cases should be localized.
1074
1075 =back
1076
1077 =head1 AUTHOR
1078
1079 Jan Büren jan@kivitendo.de
1080
1081 =cut