Shopware6: Status completed innerhalb des Konnektors mappen
[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   # one state differs
705   $transition = 'complete' if $transition eq 'completed';
706
707   croak "No shop order ID, should be in format [0-9a-f]{32}" unless $order_id   =~ m/^[0-9a-f]{32}$/;
708   croak "NO valid transition value"                          unless $transition =~ m/(open|process|cancel|complete)/;
709   my $ret;
710   $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
711   my $response_code = $ret->responseCode();
712   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
713
714 }
715
716 sub init_connector {
717   my ($self) = @_;
718
719   my $protocol = $self->config->server =~ /(^https:\/\/|^http:\/\/)/ ? '' : $self->config->protocol . '://';
720   my $client   = REST::Client->new(host => $protocol . $self->config->server);
721
722   $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
723   $client->addHeader('Content-Type', 'application/json');
724   $client->addHeader('charset',      'UTF-8');
725   $client->addHeader('Accept',       'application/json');
726
727   my %auth_req = (
728                    client_id     => $self->config->login,
729                    client_secret => $self->config->password,
730                    grant_type    => "client_credentials",
731                  );
732
733   my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
734
735   unless (200 == $ret->responseCode()) {
736     $self->{errors} .= $ret->responseContent();
737     return;
738   }
739
740   my $token = from_json($client->responseContent())->{access_token};
741   unless ($token) {
742     $self->{errors} .= "No Auth-Token received";
743     return;
744   }
745   # persist refresh token
746   $client->addHeader('Authorization' => 'Bearer ' . $token);
747   return $client;
748 }
749
750 sub import_data_to_shop_order {
751   my ($self, $import) = @_;
752
753   # failsafe checks for not yet implemented
754   die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
755
756   # no mapping unless we also have at least one shop order item ...
757   my $order_pos = delete $import->{lineItems};
758   croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
759
760   my $shop_order = $self->map_data_to_shoporder($import);
761
762   my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
763     $shop_order->save;
764     my $id = $shop_order->id;
765
766     my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
767     my $position = 0;
768     my $active_price_source = $self->config->price_source;
769     #Mapping Positions
770     foreach my $pos (@positions) {
771       $position++;
772       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
773       my %pos_columns = ( description          => $pos->{product}->{name},
774                           partnumber           => $pos->{product}->{productNumber},
775                           price                => $price,
776                           quantity             => $pos->{quantity},
777                           position             => $position,
778                           tax_rate             => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
779                           shop_trans_id        => $pos->{id}, # pos id or shop_trans_id ? or dont care?
780                           shop_order_id        => $id,
781                           active_price_source  => $active_price_source,
782                         );
783       my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
784       $pos_insert->save;
785     }
786     $shop_order->positions($position);
787
788     if ( $self->config->shipping_costs_parts_id ) {
789       die t8("Not yet implemented");
790       # TODO NOT YET Implemented nor tested, this is shopware5 code:
791       my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
792       my %shipping_pos = ( description    => $import->{data}->{dispatch}->{name},
793                            partnumber     => $shipping_part->partnumber,
794                            price          => $import->{data}->{invoiceShipping},
795                            quantity       => 1,
796                            position       => $position,
797                            shop_trans_id  => 0,
798                            shop_order_id  => $id,
799                          );
800       my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
801       $shipping_pos_insert->save;
802     }
803
804     my $customer = $shop_order->get_customer;
805
806     if (ref $customer eq 'SL::DB::Customer') {
807       $shop_order->kivi_customer_id($customer->id);
808     }
809     $shop_order->save;
810
811     # update state in shopware before transaction ends
812     $self->set_orderstatus($shop_order->shop_trans_id, "process");
813
814     1;
815
816   }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
817                 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
818 }
819
820 sub map_data_to_shoporder {
821   my ($self, $import) = @_;
822
823   croak "Expect a hash with one order." unless ref $import eq 'HASH';
824   # we need one number and a order date, some total prices and one customer
825   croak "Does not look like a shopware6 order" unless    $import->{orderNumber}
826                                                       && $import->{orderDateTime}
827                                                       && ref $import->{price} eq 'HASH'
828                                                       && ref $import->{orderCustomer} eq 'HASH';
829
830   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
831   die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
832
833   my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} }       @{ $import->{addresses} } ];
834   my $shipto_ary  = [ grep { $_->{id} == $shipto_id }                        @{ $import->{addresses} } ];
835   my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} }        @{ $import->{paymentMethods} } ];
836
837   die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
838           $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
839     unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
840
841   my $billing = $billing_ary->[0];
842   my $shipto  = $shipto_ary->[0];
843   # TODO payment info is not used at all
844   my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
845
846   # check mandatory fields from shopware
847   die t8("No billing city")   unless $billing->{city};
848   die t8("No shipto city")    unless $shipto->{city};
849   die t8("No customer email") unless $import->{orderCustomer}->{email};
850
851   # extract order date
852   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
853                                                locale    => 'de_DE',
854                                                time_zone => 'local'             );
855   my $orderdate;
856   try {
857     $orderdate = $parser->parse_datetime($import->{orderDateTime});
858   } catch { die "Cannot parse Order Date" . $_ };
859
860   my $shop_id      = $self->config->id;
861   my $tax_included = $self->config->pricetype;
862
863   # TODO copied from shopware5 connector
864   # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
865   my %payment_ids_methods = (
866     # shopware_paymentId => kivitendo_payment_id
867   );
868   my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
869   my $default_payment_id = $default_payment ? $default_payment->id : undef;
870   #
871
872
873   my %columns = (
874     amount                  => $import->{amountTotal},
875     billing_city            => $billing->{city},
876     billing_company         => $billing->{company},
877     billing_country         => $billing->{country}->{name},
878     billing_department      => $billing->{department},
879     billing_email           => $import->{orderCustomer}->{email},
880     billing_fax             => $billing->{fax},
881     billing_firstname       => $billing->{firstName},
882     #billing_greeting        => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
883     billing_lastname        => $billing->{lastName},
884     billing_phone           => $billing->{phone},
885     billing_street          => $billing->{street},
886     billing_vat             => $billing->{vatId},
887     billing_zipcode         => $billing->{zipcode},
888     customer_city           => $billing->{city},
889     customer_company        => $billing->{company},
890     customer_country        => $billing->{country}->{name},
891     customer_department     => $billing->{department},
892     customer_email          => $billing->{email},
893     customer_fax            => $billing->{fax},
894     customer_firstname      => $billing->{firstName},
895     #customer_greeting       => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
896     customer_lastname       => $billing->{lastName},
897     customer_phone          => $billing->{phoneNumber},
898     customer_street         => $billing->{street},
899     customer_vat            => $billing->{vatId},
900     customer_zipcode        => $billing->{zipcode},
901 #    customer_newsletter     => $customer}->{newsletter},
902     delivery_city           => $shipto->{city},
903     delivery_company        => $shipto->{company},
904     delivery_country        => $shipto->{country}->{name},
905     delivery_department     => $shipto->{department},
906     delivery_email          => "",
907     delivery_fax            => $shipto->{fax},
908     delivery_firstname      => $shipto->{firstName},
909     #delivery_greeting       => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
910     delivery_lastname       => $shipto->{lastName},
911     delivery_phone          => $shipto->{phone},
912     delivery_street         => $shipto->{street},
913     delivery_vat            => $shipto->{vatId},
914     delivery_zipcode        => $shipto->{zipCode},
915 #    host                    => $shop}->{hosts},
916     netamount               => $import->{amountNet},
917     order_date              => $orderdate,
918     payment_description     => $payment->{name},
919     payment_id              => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
920     tax_included            => $tax_included eq "brutto" ? 1 : 0,
921     shop_ordernumber        => $import->{orderNumber},
922     shop_id                 => $shop_id,
923     shop_trans_id           => $import->{id},
924     # TODO map these:
925     #remote_ip               => $import->{remoteAddress},
926     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
927     #sepa_bic                => $import->{paymentIntances}->{bic},
928     #sepa_iban               => $import->{paymentIntances}->{iban},
929     #shipping_costs          => $import->{invoiceShipping},
930     #shipping_costs_net      => $import->{invoiceShippingNet},
931     #shop_c_billing_id       => $import->{billing}->{customerId},
932     #shop_c_billing_number   => $import->{billing}->{number},
933     #shop_c_delivery_id      => $import->{shipping}->{id},
934     #shop_customer_id        => $import->{customerId},
935     #shop_customer_number    => $import->{billing}->{number},
936     #shop_customer_comment   => $import->{customerComment},
937   );
938
939   my $shop_order = SL::DB::ShopOrder->new(%columns);
940   return $shop_order;
941 }
942
943 sub _u8 {
944   my ($value) = @_;
945   return encode('UTF-8', $value // '');
946 }
947
948 1;
949
950 __END__
951
952 =encoding utf-8
953
954 =head1 NAME
955
956   SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
957
958 =head1 SYNOPSIS
959
960
961 =head1 DESCRIPTION
962
963 =head1 AVAILABLE METHODS
964
965 =over 4
966
967 =item C<get_one_order>
968
969 =item C<get_new_orders>
970
971 =item C<update_part>
972
973 Updates all metadata for a shop part. See base class for a general description.
974 Specific Implementation notes:
975 =over 4
976
977 =item Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
978
979 =item Checks if longdescription should be taken from part or shop_part
980
981 =item Checks if a language with the name 'Englisch' or template_code 'en'
982       is available and sets the shopware6 'en-GB' locales for the product
983
984 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
985
986 The connecting key for shopware to kivi images is the image name.
987 To get distinct entries the kivi partnumber is combined with the title (description)
988 of the image. Therefore part1000_someTitlefromUser should be unique in
989 Shopware.
990 All image data is simply send to shopware whether or not image data
991 has been edited recently.
992 If set_cover is set, the image with the position 1 will be used as
993 the shopware cover image.
994 If delete_orphaned ist set, all images related to the shopware product
995 which are not also in kivitendo will be deleted.
996 Shopware (6.4.x) takes care of deleting all the relations if the media
997 entry for the image is deleted.
998 More on media and Shopware6 can be found here:
999 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
1000
1001 =back
1002
1003 =over 4
1004
1005 =item C<get_article>
1006
1007 =item C<get_categories>
1008
1009 =item C<get_version>
1010
1011 Tries to establish a connection and in a second step
1012 tries to get the server's version number.
1013 Returns a hashref with the data structure the Base class expects.
1014
1015 =item C<set_orderstatus>
1016
1017 =item C<init_connector>
1018
1019 Inits the connection to the REST Server.
1020 Errors are collected in $self->{errors} and undef will be returned.
1021 If successful returns a REST::Client object for further communications.
1022
1023 =back
1024
1025 =head1 SEE ALSO
1026
1027 L<SL::ShopConnector::ALL>
1028
1029 =head1 BUGS
1030
1031 None yet. :)
1032
1033 =head1 TODOS
1034
1035 =over 4
1036
1037 =item * Map all data to shop_order
1038
1039 Missing fields are commented in the sub map_data_to_shoporder.
1040 Some items are SEPA debit info, IP adress, delivery costs etc
1041 Furthermore Shopware6 uses currency, country and locales information.
1042 Detailed list:
1043
1044     #customer_newsletter     => $customer}->{newsletter},
1045     #remote_ip               => $import->{remoteAddress},
1046     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
1047     #sepa_bic                => $import->{paymentIntances}->{bic},
1048     #sepa_iban               => $import->{paymentIntances}->{iban},
1049     #shipping_costs          => $import->{invoiceShipping},
1050     #shipping_costs_net      => $import->{invoiceShippingNet},
1051     #shop_c_billing_id       => $import->{billing}->{customerId},
1052     #shop_c_billing_number   => $import->{billing}->{number},
1053     #shop_c_delivery_id      => $import->{shipping}->{id},
1054     #shop_customer_id        => $import->{customerId},
1055     #shop_customer_number    => $import->{billing}->{number},
1056     #shop_customer_comment   => $import->{customerComment},
1057
1058 =item * Use shipping_costs_parts_id for additional shipping costs
1059
1060 Currently dies if a shipping_costs_parts_id is set in the config
1061
1062 =item * Payment Infos can be read from shopware but is not linked with kivi
1063
1064 Unused data structures in sub map_data_to_shoporder => payment_ary
1065
1066 =item * Delete orphaned images is new in this connector, but should be in a separate method
1067
1068 =item * Fetch from last order number is ignored and should not be needed
1069
1070 Fetch orders also sets the state of the order from open to process. The state setting
1071 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
1072 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
1073 and ignores any shopware order transition state.
1074
1075 =item * Get one order and get new orders is basically the same except for the filter
1076
1077 Right now the returning structure and the common parts of the filter are in two separate functions
1078
1079 =item * Locales!
1080
1081 Many error messages are thrown, but at least the more common cases should be localized.
1082
1083 =item * Multi language support
1084
1085 By guessing the correct german name for the english language some translation for parts can
1086 also be synced. This should be more clear (language configuration for shops) and the order
1087 synchronisation should also handle this (longdescription is simply copied from part.notes)
1088
1089 =back
1090
1091 =head1 AUTHOR
1092
1093 Jan Büren jan@kivitendo.de
1094
1095 =cut