f8a22a9112bc2cfca556040565922a853c130ade
[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     unless (204 == $ret->responseCode()) {
184       die t8('Part Description is too long for this Shopware version. It should have lower than 255 characters.')
185          if $ret->responseContent() =~ m/Diese Zeichenkette ist zu lang. Sie sollte.*255 Zeichen/;
186       die "Updating part with " .  $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
187     }
188   } else {
189     # create part
190     # 1. get the correct tax for this product
191     my $tax_filter = {
192           'filter' => [
193                         {
194                           'value' => $taxrate,
195                           'type' => 'equals',
196                           'field' => 'taxRate'
197                         }
198                       ]
199         };
200     $ret = $self->connector->POST('api/search/tax', to_json($tax_filter));
201     die "Search for Tax with rate: " .  $part->partnumber . " failed: " . $ret->responseContent() unless (200 == $ret->responseCode());
202     try {
203       $update_p->{taxId} = from_json($ret->responseContent())->{data}->[0]->{id};
204     } catch { die "Malformed JSON Data or Taxkey entry missing: $_ " . $ret->responseContent();  };
205
206     # 2. get the correct currency for this product
207     my $currency_filter = {
208         'filter' => [
209                       {
210                         'value' => SL::DB::Default->get_default_currency,
211                         'type' => 'equals',
212                         'field' => 'isoCode'
213                       }
214                     ]
215       };
216     $ret = $self->connector->POST('api/search/currency', to_json($currency_filter));
217     die "Search for Currency with ISO Code: " . SL::DB::Default->get_default_currency . " failed: "
218       . $ret->responseContent() unless (200 == $ret->responseCode());
219
220     try {
221       $update_p->{price}->[0]->{currencyId} = from_json($ret->responseContent())->{data}->[0]->{id};
222     } catch { die "Malformed JSON Data or Currency ID entry missing: $_ " . $ret->responseContent();  };
223
224     # 3. add net and gross price and allow variants
225     $update_p->{price}->[0]->{gross}  = $gross;
226     $update_p->{price}->[0]->{net}    = $net;
227     $update_p->{price}->[0]->{linked} = \1; # link product variants
228
229     $ret = $self->connector->POST('api/product', to_json($update_p));
230     die "Create for Product " .  $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
231   }
232
233   # if there are images try to sync this with the shop_part
234   try {
235     $self->sync_all_images(shop_part => $shop_part, set_cover => 1, delete_orphaned => 1);
236   } catch { die "Could not sync images for Part " . $part->partnumber . " Reason: $_" };
237
238   # if there are categories try to sync this with the shop_part
239   try {
240     $self->sync_all_categories(shop_part => $shop_part);
241   } catch { die "Could not sync Categories for Part " . $part->partnumber . " Reason: $_" };
242
243   return 1; # no invalid response code -> success
244 }
245 sub sync_all_categories {
246   my ($self, %params) = @_;
247
248   my $shop_part = delete $params{shop_part};
249   croak "Need a valid Shop Part for updating Images" unless ref($shop_part) eq 'SL::DB::ShopPart';
250
251   my $partnumber = $shop_part->part->partnumber;
252   die "Shop Part but no kivi Partnumber" unless $partnumber;
253
254   my ($ret, $response_code);
255   # 1 get  uuid for product
256   my $product_filter = {
257           'filter' => [
258                         {
259                           'value' => $partnumber,
260                           'type'  => 'equals',
261                           'field' => 'productNumber'
262                         }
263                       ]
264     };
265
266   $ret = $self->connector->POST('api/search/product', to_json($product_filter));
267   $response_code = $ret->responseCode();
268   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
269   my ($product_id, $category_tree);
270   try {
271     $product_id    = from_json($ret->responseContent())->{data}->[0]->{id};
272     $category_tree = from_json($ret->responseContent())->{data}->[0]->{categoryIds};
273   } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
274   my $cat;
275   # if the part is connected to a category at all
276   if ($shop_part->shop_category) {
277     foreach my $row_cat (@{ $shop_part->shop_category }) {
278       $cat->{@{ $row_cat }[0]} = @{ $row_cat }[1];
279     }
280   }
281   # delete
282   foreach my $shopware_cat (@{ $category_tree }) {
283     if ($cat->{$shopware_cat}) {
284       # cat exists and no delete
285       delete $cat->{$shopware_cat};
286       next;
287     }
288     # cat exists and delete
289     $ret = $self->connector->DELETE("api/product/$product_id/categories/$shopware_cat");
290     $response_code = $ret->responseCode();
291     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
292   }
293   # now add only new categories
294   my $p;
295   $p->{id}  = $product_id;
296   $p->{categories} = ();
297   foreach my $new_cat (keys %{ $cat }) {
298     push @{ $p->{categories} }, {id => $new_cat};
299   }
300     $ret = $self->connector->PATCH("api/product/$product_id", to_json($p));
301     $response_code = $ret->responseCode();
302     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
303 }
304
305 sub sync_all_images {
306   my ($self, %params) = @_;
307
308   $params{set_cover}       //= 1;
309   $params{delete_orphaned} //= 0;
310
311   my $shop_part = delete $params{shop_part};
312   croak "Need a valid Shop Part for updating Images" unless ref($shop_part) eq 'SL::DB::ShopPart';
313
314   my $partnumber = $shop_part->part->partnumber;
315   die "Shop Part but no kivi Partnumber" unless $partnumber;
316
317   my @upload_img  = $shop_part->get_images(want_binary => 1);
318
319   return unless (@upload_img); # there are no images, but delete wont work TODO extract to method
320
321   my ($ret, $response_code);
322   # 1. get part uuid and get media associations
323   # 2. create or update the media entry for the filename
324   # 2.1 if no media entry exists create one
325   # 2.2 update file
326   # 2.2 create or update media_product and set position
327   # 3. optional set cover image
328   # 4. optional delete images in shopware which are not in kivi
329
330   # 1 get mediaid uuid for prodcut
331   my $product_filter = {
332               'associations' => {
333                 'media'   => []
334               },
335           'filter' => [
336                         {
337                           'value' => $partnumber,
338                           'type'  => 'equals',
339                           'field' => 'productNumber'
340                         }
341                       ]
342     };
343
344   $ret = $self->connector->POST('api/search/product', to_json($product_filter));
345   $response_code = $ret->responseCode();
346   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
347   my ($product_id, $media_data);
348   try {
349     $product_id = from_json($ret->responseContent())->{data}->[0]->{id};
350     # $media_data = from_json($ret->responseContent())->{data}->[0]->{media};
351   } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
352
353   # 2 iterate all kivi images and save distinct name for later sync
354   my %existing_images;
355   foreach my $img (@upload_img) {
356     die $::locale->text("Need a image title") unless $img->{description};
357     my $distinct_media_name = $partnumber . '_' . $img->{description};
358     $existing_images{$distinct_media_name} = 1;
359     my $image_filter = {  'filter' => [
360                           {
361                             'value' => $distinct_media_name,
362                             'type'  => 'equals',
363                             'field' => 'fileName'
364                           }
365                         ]
366                       };
367     $ret           = $self->connector->POST('api/search/media', to_json($image_filter));
368     $response_code = $ret->responseCode();
369     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
370     my $current_image_id; # maybe empty
371     try {
372       $current_image_id = from_json($ret->responseContent())->{data}->[0]->{id};
373     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
374
375     # 2.1 no image with this title, create metadata for media and upload image
376     if (!$current_image_id) {
377       # not yet uploaded, create media entry
378       $ret = $self->connector->POST("/api/media?_response=true");
379       $response_code = $ret->responseCode();
380       die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
381       try {
382         $current_image_id = from_json($ret->responseContent())->{data}{id};
383       } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
384     }
385     # 2.2 update the image data (current_image_id was found or created)
386     $ret = $self->connector->POST("/api/_action/media/$current_image_id/upload?fileName=$distinct_media_name&extension=$img->{extension}",
387                                     $img->{link},
388                                    {
389                                     "Content-Type"  => "image/$img->{extension}",
390                                    });
391     $response_code = $ret->responseCode();
392     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
393
394     # 2.3 check if a product media entry exists for this id
395     my $product_media_filter = {
396               'filter' => [
397                         {
398                           'value' => $product_id,
399                           'type' => 'equals',
400                           'field' => 'productId'
401                         },
402                         {
403                           'value' => $current_image_id,
404                           'type' => 'equals',
405                           'field' => 'mediaId'
406                         },
407                       ]
408         };
409     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
410     $response_code = $ret->responseCode();
411     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
412     my ($has_product_media, $product_media_id);
413     try {
414       $has_product_media = from_json($ret->responseContent())->{total};
415       $product_media_id  = from_json($ret->responseContent())->{data}->[0]->{id};
416     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
417
418     # 2.4 ... and either update or create the entry
419     #     set shopware position to kivi position
420     my $product_media;
421     $product_media->{position} = $img->{position}; # position may change
422
423     if ($has_product_media == 0) {
424       # 2.4.1 new entry. link product to media
425       $product_media->{productId} = $product_id;
426       $product_media->{mediaId}   = $current_image_id;
427       $ret = $self->connector->POST('api/product-media', to_json($product_media));
428     } elsif ($has_product_media == 1 && $product_media_id) {
429       $ret = $self->connector->PATCH("api/product-media/$product_media_id", to_json($product_media));
430     } else {
431       die "Invalid state, please inform Shopware master admin at product-media filter: $product_media_filter";
432     }
433     $response_code = $ret->responseCode();
434     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
435   }
436   # 3. optional set image with position 1 as cover image
437   if ($params{set_cover}) {
438     # set cover if position == 1
439     my $product_media_filter = {
440               'filter' => [
441                         {
442                           'value' => $product_id,
443                           'type' => 'equals',
444                           'field' => 'productId'
445                         },
446                         {
447                           'value' => '1',
448                           'type' => 'equals',
449                           'field' => 'position'
450                         },
451                           ]
452                              };
453
454     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
455     $response_code = $ret->responseCode();
456     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
457     my $cover;
458     try {
459       $cover->{coverId} = from_json($ret->responseContent())->{data}->[0]->{id};
460     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
461     $ret = $self->connector->PATCH('api/product/' . $product_id, to_json($cover));
462     $response_code = $ret->responseCode();
463     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
464   }
465   # 4. optional delete orphaned images in shopware
466   if ($params{delete_orphaned}) {
467     # delete orphaned images
468     my $product_media_filter = {
469               'filter' => [
470                         {
471                           'value' => $product_id,
472                           'type' => 'equals',
473                           'field' => 'productId'
474                         }, ] };
475     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
476     $response_code = $ret->responseCode();
477     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
478     my $img_ary;
479     try {
480       $img_ary = from_json($ret->responseContent())->{data};
481     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
482
483     if (scalar @{ $img_ary} > 0) { # maybe no images at all
484       my %existing_img;
485       $existing_img{$_->{media}->{fileName}}= $_->{media}->{id} for @{ $img_ary };
486
487       while (my ($name, $id) = each %existing_img) {
488         next if $existing_images{$name};
489         $ret = $self->connector->DELETE("api/media/$id");
490         $response_code = $ret->responseCode();
491         die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
492       }
493     }
494   }
495   return;
496 }
497
498 sub get_categories {
499   my ($self) = @_;
500
501   my $ret           = $self->connector->POST('api/search/category');
502   my $response_code = $ret->responseCode();
503
504   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
505
506   my $import;
507   try {
508     $import = decode_json $ret->responseContent();
509   } catch {
510     die "Malformed JSON Data: $_ " . $ret->responseContent();
511   };
512
513   my @daten      = @{ $import->{data} };
514   my %categories = map { ($_->{id} => $_) } @daten;
515
516   my @categories_tree;
517   for (@daten) {
518     my $parent = $categories{$_->{parentId}};
519     if ($parent) {
520       $parent->{children} ||= [];
521       push @{ $parent->{children} }, $_;
522     } else {
523       push @categories_tree, $_;
524     }
525   }
526   return \@categories_tree;
527 }
528
529 sub get_one_order  {
530   my ($self, $ordnumber) = @_;
531
532   croak t8("No Order Number") unless $ordnumber;
533   # set known params for the return structure
534   my %fetched_order  = $self->get_fetched_order_structure;
535   my $assoc          = $self->all_open_orders();
536
537   # overwrite filter for exactly one ordnumber
538   $assoc->{filter}->[0]->{value} = $ordnumber;
539   $assoc->{filter}->[0]->{type}  = 'equals';
540   $assoc->{filter}->[0]->{field} = 'orderNumber';
541
542   # 1. fetch the order and import it as a kivi order
543   # 2. return the number of processed order (1)
544   my $one_order = $self->connector->POST('api/search/order', to_json($assoc));
545
546   # 1. check for bad request or connection problems
547   if ($one_order->responseCode() != 200) {
548     $fetched_order{error}   = 1;
549     $fetched_order{message} = $one_order->responseCode() . ' ' . $one_order->responseContent();
550     return \%fetched_order;
551   }
552
553   # 1.1 parse json or exit
554   my $content;
555   try {
556     $content = from_json($one_order->responseContent());
557   } catch {
558     $fetched_order{error}   = 1;
559     $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
560     return \%fetched_order;
561   };
562
563   # 2. check if we found ONE order at all
564   my $total = $content->{total};
565   if ($total == 0) {
566     $fetched_order{number_of_orders} = 0;
567     return \%fetched_order;
568   } elsif ($total != 1) {
569     $fetched_order{error}   = 1;
570     $fetched_order{message} = "More than one Order returned. Invalid State: $total";
571     return \%fetched_order;
572   }
573
574   # 3. there is one valid order, try to import this one
575   if ($self->import_data_to_shop_order($content->{data}->[0])) {
576     %fetched_order = (shop_description => $self->config->description, number_of_orders => 1);
577   } else {
578     $fetched_order{message} = "Error: $@";
579     $fetched_order{error}   = 1;
580   }
581   return \%fetched_order;
582 }
583
584 sub get_new_orders {
585   my ($self) = @_;
586
587   my %fetched_order  = $self->get_fetched_order_structure;
588   my $assoc          = $self->all_open_orders();
589
590   # 1. fetch all open orders and try to import it as a kivi order
591   # 2. return the number of processed order $total
592   my $open_orders = $self->connector->POST('api/search/order', to_json($assoc));
593
594   # 1. check for bad request or connection problems
595   if ($open_orders->responseCode() != 200) {
596     $fetched_order{error}   = 1;
597     $fetched_order{message} = $open_orders->responseCode() . ' ' . $open_orders->responseContent();
598     return \%fetched_order;
599   }
600
601   # 1.1 parse json or exit
602   my $content;
603   try {
604     $content = from_json($open_orders->responseContent());
605   } catch {
606     $fetched_order{error}   = 1;
607     $fetched_order{message} = "Malformed JSON Data: $_ " . $open_orders->responseContent();
608     return \%fetched_order;
609   };
610
611   # 2. check if we found one or more order at all
612   my $total = $content->{total};
613   if ($total == 0) {
614     $fetched_order{number_of_orders} = 0;
615     return \%fetched_order;
616   } elsif (!$total || !($total > 0)) {
617     $fetched_order{error}   = 1;
618     $fetched_order{message} = "Undefined value for total orders returned. Invalid State: $total";
619     return \%fetched_order;
620   }
621
622   # 3. there are open orders. try to import one by one
623   $fetched_order{number_of_orders} = 0;
624   foreach my $open_order (@{ $content->{data} }) {
625     if ($self->import_data_to_shop_order($open_order)) {
626       $fetched_order{number_of_orders}++;
627     } else {
628       $fetched_order{message} .= "Error at importing order with running number:"
629                                   . $fetched_order{number_of_orders}+1 . ": $@ \n";
630       $fetched_order{error}    = 1;
631     }
632   }
633   return \%fetched_order;
634 }
635
636 sub get_article {
637   my ($self, $partnumber) = @_;
638
639   $partnumber   = $::form->escape($partnumber);
640   my $product_filter = {
641               'filter' => [
642                             {
643                               'value' => $partnumber,
644                               'type' => 'equals',
645                               'field' => 'productNumber'
646                             }
647                           ]
648                        };
649   my $ret = $self->connector->POST('api/search/product', to_json($product_filter));
650
651   my $response_code = $ret->responseCode();
652   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
653
654   my $data_json;
655   try {
656     $data_json = decode_json $ret->responseContent();
657   } catch {
658     die "Malformed JSON Data: $_ " . $ret->responseContent();
659   };
660
661   # maybe no product was found ...
662   return undef unless scalar @{ $data_json->{data} } > 0;
663   # caller wants this structure:
664   # $stock_onlineshop = $shop_article->{data}->{mainDetail}->{inStock};
665   # $active_online = $shop_article->{data}->{active};
666   my $data;
667   $data->{data}->{mainDetail}->{inStock} = $data_json->{data}->[0]->{stock};
668   $data->{data}->{active}                = $data_json->{data}->[0]->{active};
669   return $data;
670 }
671
672 sub get_version {
673   my ($self) = @_;
674
675   my $return  = {}; # return for caller
676   my $ret     = {}; # internal return
677
678   #  1. check if we can connect at all
679   #  2. request version number
680
681   $ret = $self->connector;
682   if (200 != $ret->responseCode()) {
683     $return->{success}         = 0;
684     $return->{data}->{version} = $self->{errors}; # whatever init puts in errors
685     return $return;
686   }
687
688   $ret = $self->connector->GET('api/_info/version');
689   if (200 == $ret->responseCode()) {
690     my $version = from_json($self->connector->responseContent())->{version};
691     $return->{success}         = 1;
692     $return->{data}->{version} = $version;
693   } else {
694     $return->{success}         = 0;
695     $return->{data}->{version} = $ret->responseContent(); # Whatever REST Interface says
696   }
697
698   return $return;
699 }
700
701 sub set_orderstatus {
702   my ($self, $order_id, $transition) = @_;
703
704   croak "No shop order ID, should be in format [0-9a-f]{32}" unless $order_id   =~ m/^[0-9a-f]{32}$/;
705   croak "NO valid transition value"                          unless $transition =~ m/(open|process|cancel|complete)/;
706   my $ret;
707   $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
708   my $response_code = $ret->responseCode();
709   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
710
711 }
712
713 sub init_connector {
714   my ($self) = @_;
715
716   my $protocol = $self->config->server =~ /(^https:\/\/|^http:\/\/)/ ? '' : $self->config->protocol . '://';
717   my $client   = REST::Client->new(host => $protocol . $self->config->server);
718
719   $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
720   $client->addHeader('Content-Type', 'application/json');
721   $client->addHeader('charset',      'UTF-8');
722   $client->addHeader('Accept',       'application/json');
723
724   my %auth_req = (
725                    client_id     => $self->config->login,
726                    client_secret => $self->config->password,
727                    grant_type    => "client_credentials",
728                  );
729
730   my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
731
732   unless (200 == $ret->responseCode()) {
733     $self->{errors} .= $ret->responseContent();
734     return;
735   }
736
737   my $token = from_json($client->responseContent())->{access_token};
738   unless ($token) {
739     $self->{errors} .= "No Auth-Token received";
740     return;
741   }
742   # persist refresh token
743   $client->addHeader('Authorization' => 'Bearer ' . $token);
744   return $client;
745 }
746
747 sub import_data_to_shop_order {
748   my ($self, $import) = @_;
749
750   # failsafe checks for not yet implemented
751   die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
752
753   # no mapping unless we also have at least one shop order item ...
754   my $order_pos = delete $import->{lineItems};
755   croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
756
757   my $shop_order = $self->map_data_to_shoporder($import);
758
759   my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
760     $shop_order->save;
761     my $id = $shop_order->id;
762
763     my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
764     my $position = 0;
765     my $active_price_source = $self->config->price_source;
766     #Mapping Positions
767     foreach my $pos (@positions) {
768       $position++;
769       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
770       my %pos_columns = ( description          => $pos->{product}->{name},
771                           partnumber           => $pos->{product}->{productNumber},
772                           price                => $price,
773                           quantity             => $pos->{quantity},
774                           position             => $position,
775                           tax_rate             => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
776                           shop_trans_id        => $pos->{id}, # pos id or shop_trans_id ? or dont care?
777                           shop_order_id        => $id,
778                           active_price_source  => $active_price_source,
779                         );
780       my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
781       $pos_insert->save;
782     }
783     $shop_order->positions($position);
784
785     if ( $self->config->shipping_costs_parts_id ) {
786       die t8("Not yet implemented");
787       # TODO NOT YET Implemented nor tested, this is shopware5 code:
788       my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
789       my %shipping_pos = ( description    => $import->{data}->{dispatch}->{name},
790                            partnumber     => $shipping_part->partnumber,
791                            price          => $import->{data}->{invoiceShipping},
792                            quantity       => 1,
793                            position       => $position,
794                            shop_trans_id  => 0,
795                            shop_order_id  => $id,
796                          );
797       my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
798       $shipping_pos_insert->save;
799     }
800
801     my $customer = $shop_order->get_customer;
802
803     if (ref $customer eq 'SL::DB::Customer') {
804       $shop_order->kivi_customer_id($customer->id);
805     }
806     $shop_order->save;
807
808     # update state in shopware before transaction ends
809     $self->set_orderstatus($shop_order->shop_trans_id, "process");
810
811     1;
812
813   }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
814                 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
815 }
816
817 sub map_data_to_shoporder {
818   my ($self, $import) = @_;
819
820   croak "Expect a hash with one order." unless ref $import eq 'HASH';
821   # we need one number and a order date, some total prices and one customer
822   croak "Does not look like a shopware6 order" unless    $import->{orderNumber}
823                                                       && $import->{orderDateTime}
824                                                       && ref $import->{price} eq 'HASH'
825                                                       && ref $import->{orderCustomer} eq 'HASH';
826
827   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
828   die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
829
830   my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} }       @{ $import->{addresses} } ];
831   my $shipto_ary  = [ grep { $_->{id} == $shipto_id }                        @{ $import->{addresses} } ];
832   my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} }        @{ $import->{paymentMethods} } ];
833
834   die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
835           $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
836     unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
837
838   my $billing = $billing_ary->[0];
839   my $shipto  = $shipto_ary->[0];
840   # TODO payment info is not used at all
841   my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
842
843   # check mandatory fields from shopware
844   die t8("No billing city")   unless $billing->{city};
845   die t8("No shipto city")    unless $shipto->{city};
846   die t8("No customer email") unless $import->{orderCustomer}->{email};
847
848   # extract order date
849   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
850                                                locale    => 'de_DE',
851                                                time_zone => 'local'             );
852   my $orderdate;
853   try {
854     $orderdate = $parser->parse_datetime($import->{orderDateTime});
855   } catch { die "Cannot parse Order Date" . $_ };
856
857   my $shop_id      = $self->config->id;
858   my $tax_included = $self->config->pricetype;
859
860   # TODO copied from shopware5 connector
861   # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
862   my %payment_ids_methods = (
863     # shopware_paymentId => kivitendo_payment_id
864   );
865   my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
866   my $default_payment_id = $default_payment ? $default_payment->id : undef;
867   #
868
869
870   my %columns = (
871     amount                  => $import->{amountTotal},
872     billing_city            => $billing->{city},
873     billing_company         => $billing->{company},
874     billing_country         => $billing->{country}->{name},
875     billing_department      => $billing->{department},
876     billing_email           => $import->{orderCustomer}->{email},
877     billing_fax             => $billing->{fax},
878     billing_firstname       => $billing->{firstName},
879     #billing_greeting        => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
880     billing_lastname        => $billing->{lastName},
881     billing_phone           => $billing->{phone},
882     billing_street          => $billing->{street},
883     billing_vat             => $billing->{vatId},
884     billing_zipcode         => $billing->{zipcode},
885     customer_city           => $billing->{city},
886     customer_company        => $billing->{company},
887     customer_country        => $billing->{country}->{name},
888     customer_department     => $billing->{department},
889     customer_email          => $billing->{email},
890     customer_fax            => $billing->{fax},
891     customer_firstname      => $billing->{firstName},
892     #customer_greeting       => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
893     customer_lastname       => $billing->{lastName},
894     customer_phone          => $billing->{phoneNumber},
895     customer_street         => $billing->{street},
896     customer_vat            => $billing->{vatId},
897     customer_zipcode        => $billing->{zipcode},
898 #    customer_newsletter     => $customer}->{newsletter},
899     delivery_city           => $shipto->{city},
900     delivery_company        => $shipto->{company},
901     delivery_country        => $shipto->{country}->{name},
902     delivery_department     => $shipto->{department},
903     delivery_email          => "",
904     delivery_fax            => $shipto->{fax},
905     delivery_firstname      => $shipto->{firstName},
906     #delivery_greeting       => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
907     delivery_lastname       => $shipto->{lastName},
908     delivery_phone          => $shipto->{phone},
909     delivery_street         => $shipto->{street},
910     delivery_vat            => $shipto->{vatId},
911     delivery_zipcode        => $shipto->{zipCode},
912 #    host                    => $shop}->{hosts},
913     netamount               => $import->{amountNet},
914     order_date              => $orderdate,
915     payment_description     => $payment->{name},
916     payment_id              => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
917     tax_included            => $tax_included eq "brutto" ? 1 : 0,
918     shop_ordernumber        => $import->{orderNumber},
919     shop_id                 => $shop_id,
920     shop_trans_id           => $import->{id},
921     # TODO map these:
922     #remote_ip               => $import->{remoteAddress},
923     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
924     #sepa_bic                => $import->{paymentIntances}->{bic},
925     #sepa_iban               => $import->{paymentIntances}->{iban},
926     #shipping_costs          => $import->{invoiceShipping},
927     #shipping_costs_net      => $import->{invoiceShippingNet},
928     #shop_c_billing_id       => $import->{billing}->{customerId},
929     #shop_c_billing_number   => $import->{billing}->{number},
930     #shop_c_delivery_id      => $import->{shipping}->{id},
931     #shop_customer_id        => $import->{customerId},
932     #shop_customer_number    => $import->{billing}->{number},
933     #shop_customer_comment   => $import->{customerComment},
934   );
935
936   my $shop_order = SL::DB::ShopOrder->new(%columns);
937   return $shop_order;
938 }
939
940 sub _u8 {
941   my ($value) = @_;
942   return encode('UTF-8', $value // '');
943 }
944
945 1;
946
947 __END__
948
949 =encoding utf-8
950
951 =head1 NAME
952
953   SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
954
955 =head1 SYNOPSIS
956
957
958 =head1 DESCRIPTION
959
960 =head1 AVAILABLE METHODS
961
962 =over 4
963
964 =item C<get_one_order>
965
966 =item C<get_new_orders>
967
968 =item C<update_part>
969
970 Updates all metadata for a shop part. See base class for a general description.
971 Specific Implementation notes:
972 =over 4
973
974 =item Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
975
976 =item Checks if longdescription should be taken from part or shop_part
977
978 =item Checks if a language with the name 'Englisch' or template_code 'en'
979       is available and sets the shopware6 'en-GB' locales for the product
980
981 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
982
983 The connecting key for shopware to kivi images is the image name.
984 To get distinct entries the kivi partnumber is combined with the title (description)
985 of the image. Therefore part1000_someTitlefromUser should be unique in
986 Shopware.
987 All image data is simply send to shopware whether or not image data
988 has been edited recently.
989 If set_cover is set, the image with the position 1 will be used as
990 the shopware cover image.
991 If delete_orphaned ist set, all images related to the shopware product
992 which are not also in kivitendo will be deleted.
993 Shopware (6.4.x) takes care of deleting all the relations if the media
994 entry for the image is deleted.
995 More on media and Shopware6 can be found here:
996 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
997
998 =back
999
1000 =over 4
1001
1002 =item C<get_article>
1003
1004 =item C<get_categories>
1005
1006 =item C<get_version>
1007
1008 Tries to establish a connection and in a second step
1009 tries to get the server's version number.
1010 Returns a hashref with the data structure the Base class expects.
1011
1012 =item C<set_orderstatus>
1013
1014 =item C<init_connector>
1015
1016 Inits the connection to the REST Server.
1017 Errors are collected in $self->{errors} and undef will be returned.
1018 If successful returns a REST::Client object for further communications.
1019
1020 =back
1021
1022 =head1 SEE ALSO
1023
1024 L<SL::ShopConnector::ALL>
1025
1026 =head1 BUGS
1027
1028 None yet. :)
1029
1030 =head1 TODOS
1031
1032 =over 4
1033
1034 =item * Map all data to shop_order
1035
1036 Missing fields are commented in the sub map_data_to_shoporder.
1037 Some items are SEPA debit info, IP adress, delivery costs etc
1038 Furthermore Shopware6 uses currency, country and locales information.
1039 Detailed list:
1040
1041     #customer_newsletter     => $customer}->{newsletter},
1042     #remote_ip               => $import->{remoteAddress},
1043     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
1044     #sepa_bic                => $import->{paymentIntances}->{bic},
1045     #sepa_iban               => $import->{paymentIntances}->{iban},
1046     #shipping_costs          => $import->{invoiceShipping},
1047     #shipping_costs_net      => $import->{invoiceShippingNet},
1048     #shop_c_billing_id       => $import->{billing}->{customerId},
1049     #shop_c_billing_number   => $import->{billing}->{number},
1050     #shop_c_delivery_id      => $import->{shipping}->{id},
1051     #shop_customer_id        => $import->{customerId},
1052     #shop_customer_number    => $import->{billing}->{number},
1053     #shop_customer_comment   => $import->{customerComment},
1054
1055 =item * Use shipping_costs_parts_id for additional shipping costs
1056
1057 Currently dies if a shipping_costs_parts_id is set in the config
1058
1059 =item * Payment Infos can be read from shopware but is not linked with kivi
1060
1061 Unused data structures in sub map_data_to_shoporder => payment_ary
1062
1063 =item * Delete orphaned images is new in this connector, but should be in a separate method
1064
1065 =item * Fetch from last order number is ignored and should not be needed
1066
1067 Fetch orders also sets the state of the order from open to process. The state setting
1068 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
1069 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
1070 and ignores any shopware order transition state.
1071
1072 =item * Get one order and get new orders is basically the same except for the filter
1073
1074 Right now the returning structure and the common parts of the filter are in two separate functions
1075
1076 =item * Locales!
1077
1078 Many error messages are thrown, but at least the more common cases should be localized.
1079
1080 =item * Multi language support
1081
1082 By guessing the correct german name for the english language some translation for parts can
1083 also be synced. This should be more clear (language configuration for shops) and the order
1084 synchronisation should also handle this (longdescription is simply copied from part.notes)
1085
1086 =back
1087
1088 =head1 AUTHOR
1089
1090 Jan Büren jan@kivitendo.de
1091
1092 =cut