shopware6: Bei der Produkt-Kategorie-Zuordnung nur die Blätter löschen
[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   $client->addHeader('Content-Type', 'application/json');
714   $client->addHeader('charset',      'UTF-8');
715   $client->addHeader('Accept',       'application/json');
716
717   my %auth_req = (
718                    client_id     => $self->config->login,
719                    client_secret => $self->config->password,
720                    grant_type    => "client_credentials",
721                  );
722
723   my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
724
725   unless (200 == $ret->responseCode()) {
726     $self->{errors} .= $ret->responseContent();
727     return;
728   }
729
730   my $token = from_json($client->responseContent())->{access_token};
731   unless ($token) {
732     $self->{errors} .= "No Auth-Token received";
733     return;
734   }
735   # persist refresh token
736   $client->addHeader('Authorization' => 'Bearer ' . $token);
737   return $client;
738 }
739
740 sub import_data_to_shop_order {
741   my ($self, $import) = @_;
742
743   # failsafe checks for not yet implemented
744   die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
745
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';
749
750   my $shop_order = $self->map_data_to_shoporder($import);
751
752   my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
753     $shop_order->save;
754     my $id = $shop_order->id;
755
756     my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
757     my $position = 0;
758     my $active_price_source = $self->config->price_source;
759     #Mapping Positions
760     foreach my $pos (@positions) {
761       $position++;
762       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
763       my %pos_columns = ( description          => $pos->{product}->{description},
764                           partnumber           => $pos->{product}->{productNumber},
765                           price                => $price,
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,
772                         );
773       my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
774       $pos_insert->save;
775     }
776     $shop_order->positions($position);
777
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},
785                            quantity       => 1,
786                            position       => $position,
787                            shop_trans_id  => 0,
788                            shop_order_id  => $id,
789                          );
790       my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
791       $shipping_pos_insert->save;
792     }
793
794     my $customer = $shop_order->get_customer;
795
796     if (ref $customer eq 'SL::DB::Customer') {
797       $shop_order->kivi_customer_id($customer->id);
798     }
799     $shop_order->save;
800
801     # update state in shopware before transaction ends
802     $self->set_orderstatus($shop_order->shop_trans_id, "process");
803
804     1;
805
806   }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
807                 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
808 }
809
810 sub map_data_to_shoporder {
811   my ($self, $import) = @_;
812
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';
819
820   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
821   die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
822
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} } ];
826
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;
830
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;
835
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};
840
841   # extract order date
842   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
843                                                locale    => 'de_DE',
844                                                time_zone => 'local'             );
845   my $orderdate;
846   try {
847     $orderdate = $parser->parse_datetime($import->{orderDateTime});
848   } catch { die "Cannot parse Order Date" . $_ };
849
850   my $shop_id      = $self->config->id;
851   my $tax_included = $self->config->pricetype;
852
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
857   );
858   my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
859   my $default_payment_id = $default_payment ? $default_payment->id : undef;
860   #
861
862
863   my %columns = (
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},
912     shop_id                 => $shop_id,
913     shop_trans_id           => $import->{id},
914     # TODO map these:
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},
927   );
928
929   my $shop_order = SL::DB::ShopOrder->new(%columns);
930   return $shop_order;
931 }
932
933 sub _u8 {
934   my ($value) = @_;
935   return encode('UTF-8', $value // '');
936 }
937
938 1;
939
940 __END__
941
942 =encoding utf-8
943
944 =head1 NAME
945
946   SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
947
948 =head1 SYNOPSIS
949
950
951 =head1 DESCRIPTION
952
953 =head1 AVAILABLE METHODS
954
955 =over 4
956
957 =item C<get_one_order>
958
959 =item C<get_new_orders>
960
961 =item C<update_part>
962
963 Updates all metadata for a shop part. See base class for a general description.
964 Specific Implementation notes:
965 =over 4
966
967 =item * Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
968
969 =item * Checks if longdescription should be taken from part or shop_part
970
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
973
974 =back
975
976 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
977
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
981 Shopware.
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
992
993
994 =item C<get_article>
995
996 =item C<get_categories>
997
998 =item C<get_version>
999
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.
1003
1004 =item C<set_orderstatus>
1005
1006 =item C<init_connector>
1007
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.
1011
1012 =back
1013
1014 =head1 SEE ALSO
1015
1016 L<SL::ShopConnector::ALL>
1017
1018 =head1 BUGS
1019
1020 None yet. :)
1021
1022 =head1 TODOS
1023
1024 =over 4
1025
1026 =item * Map all data to shop_order
1027
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.
1031 Detailed list:
1032
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},
1046
1047 =item * Use shipping_costs_parts_id for additional shipping costs
1048
1049 Currently dies if a shipping_costs_parts_id is set in the config
1050
1051 =item * Payment Infos can be read from shopware but is not linked with kivi
1052
1053 Unused data structures in sub map_data_to_shoporder => payment_ary
1054
1055 =item * Delete orphaned images is new in this connector, but should be in a separate method
1056
1057 =item * Fetch from last order number is ignored and should not be needed
1058
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.
1063
1064 =item * Get one order and get new orders is basically the same except for the filter
1065
1066 Right now the returning structure and the common parts of the filter are in two separate functions
1067
1068 =item * Locales!
1069
1070 Many error messages are thrown, but at least the more common cases should be localized.
1071
1072 =back
1073
1074 =head1 AUTHOR
1075
1076 Jan Büren jan@kivitendo.de
1077
1078 =cut