]> wagnertech.de Git - mfinanz.git/blob - SL/ShopConnector/Shopware6.pm
restart apache2 in postinst
[mfinanz.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       # get media folder id
378       $ret = $self->connector->GET('api/media-folder');
379       $response_code = $ret->responseCode();
380       die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
381       my $media_folder_id;
382       try {
383         $media_folder_id = from_json($ret->responseContent())->{data}->[0]->{id};
384       } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
385
386       # not yet uploaded, create media entry
387       $ret = $self->connector->POST("/api/media?_response=true", to_json({"mediaFolderId" => $media_folder_id}));
388       $response_code = $ret->responseCode();
389       die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
390       try {
391         $current_image_id = from_json($ret->responseContent())->{data}{id};
392       } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
393     }
394     # 2.2 update the image data (current_image_id was found or created)
395     $ret = $self->connector->POST("/api/_action/media/$current_image_id/upload?fileName=$distinct_media_name&extension=$img->{extension}",
396                                     $img->{link},
397                                    {
398                                     "Content-Type"  => "image/$img->{extension}",
399                                    });
400     $response_code = $ret->responseCode();
401     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
402
403     # 2.3 check if a product media entry exists for this id
404     my $product_media_filter = {
405               'filter' => [
406                         {
407                           'value' => $product_id,
408                           'type' => 'equals',
409                           'field' => 'productId'
410                         },
411                         {
412                           'value' => $current_image_id,
413                           'type' => 'equals',
414                           'field' => 'mediaId'
415                         },
416                       ]
417         };
418     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
419     $response_code = $ret->responseCode();
420     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
421     my ($has_product_media, $product_media_id);
422     try {
423       $has_product_media = from_json($ret->responseContent())->{total};
424       $product_media_id  = from_json($ret->responseContent())->{data}->[0]->{id};
425     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
426
427     # 2.4 ... and either update or create the entry
428     #     set shopware position to kivi position
429     my $product_media;
430     $product_media->{position} = $img->{position}; # position may change
431
432     if ($has_product_media == 0) {
433       # 2.4.1 new entry. link product to media
434       $product_media->{productId} = $product_id;
435       $product_media->{mediaId}   = $current_image_id;
436       $ret = $self->connector->POST('api/product-media', to_json($product_media));
437     } elsif ($has_product_media == 1 && $product_media_id) {
438       $ret = $self->connector->PATCH("api/product-media/$product_media_id", to_json($product_media));
439     } else {
440       die "Invalid state, please inform Shopware master admin at product-media filter: $product_media_filter";
441     }
442     $response_code = $ret->responseCode();
443     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
444   }
445   # 3. optional set image with position 1 as cover image
446   if ($params{set_cover}) {
447     # set cover if position == 1
448     my $product_media_filter = {
449               'filter' => [
450                         {
451                           'value' => $product_id,
452                           'type' => 'equals',
453                           'field' => 'productId'
454                         },
455                         {
456                           'value' => '1',
457                           'type' => 'equals',
458                           'field' => 'position'
459                         },
460                           ]
461                              };
462
463     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
464     $response_code = $ret->responseCode();
465     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
466     my $cover;
467     try {
468       $cover->{coverId} = from_json($ret->responseContent())->{data}->[0]->{id};
469     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
470     $ret = $self->connector->PATCH('api/product/' . $product_id, to_json($cover));
471     $response_code = $ret->responseCode();
472     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
473   }
474   # 4. optional delete orphaned images in shopware
475   if ($params{delete_orphaned}) {
476     # delete orphaned images
477     my $product_media_filter = {
478               'filter' => [
479                         {
480                           'value' => $product_id,
481                           'type' => 'equals',
482                           'field' => 'productId'
483                         }, ] };
484     $ret = $self->connector->POST('api/search/product-media', to_json($product_media_filter));
485     $response_code = $ret->responseCode();
486     die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
487     my $img_ary;
488     try {
489       $img_ary = from_json($ret->responseContent())->{data};
490     } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
491
492     if (scalar @{ $img_ary} > 0) { # maybe no images at all
493       my %existing_img;
494       $existing_img{$_->{media}->{fileName}}= $_->{media}->{id} for @{ $img_ary };
495
496       while (my ($name, $id) = each %existing_img) {
497         next if $existing_images{$name};
498         $ret = $self->connector->DELETE("api/media/$id");
499         $response_code = $ret->responseCode();
500         die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
501       }
502     }
503   }
504   return;
505 }
506
507 sub get_categories {
508   my ($self) = @_;
509
510   my $ret           = $self->connector->POST('api/search/category');
511   my $response_code = $ret->responseCode();
512
513   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
514
515   my $import;
516   try {
517     $import = decode_json $ret->responseContent();
518   } catch {
519     die "Malformed JSON Data: $_ " . $ret->responseContent();
520   };
521
522   my @daten      = @{ $import->{data} };
523   my %categories = map { ($_->{id} => $_) } @daten;
524
525   my @categories_tree;
526   for (@daten) {
527     my $parent = $categories{$_->{parentId}};
528     if ($parent) {
529       $parent->{children} ||= [];
530       push @{ $parent->{children} }, $_;
531     } else {
532       push @categories_tree, $_;
533     }
534   }
535   return \@categories_tree;
536 }
537
538 sub get_one_order  {
539   my ($self, $ordnumber) = @_;
540
541   croak t8("No Order Number") unless $ordnumber;
542   # set known params for the return structure
543   my %fetched_order  = $self->get_fetched_order_structure;
544   my $assoc          = $self->all_open_orders();
545
546   # overwrite filter for exactly one ordnumber
547   $assoc->{filter}->[0]->{value} = $ordnumber;
548   $assoc->{filter}->[0]->{type}  = 'equals';
549   $assoc->{filter}->[0]->{field} = 'orderNumber';
550
551   # 1. fetch the order and import it as a kivi order
552   # 2. return the number of processed order (1)
553   my $one_order = $self->connector->POST('api/search/order', to_json($assoc));
554
555   # 1. check for bad request or connection problems
556   if ($one_order->responseCode() != 200) {
557     $fetched_order{error}   = 1;
558     $fetched_order{message} = $one_order->responseCode() . ' ' . $one_order->responseContent();
559     return \%fetched_order;
560   }
561
562   # 1.1 parse json or exit
563   my $content;
564   try {
565     $content = from_json($one_order->responseContent());
566   } catch {
567     $fetched_order{error}   = 1;
568     $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
569     return \%fetched_order;
570   };
571
572   # 2. check if we found ONE order at all
573   my $total = $content->{total};
574   if ($total == 0) {
575     $fetched_order{number_of_orders} = 0;
576     return \%fetched_order;
577   } elsif ($total != 1) {
578     $fetched_order{error}   = 1;
579     $fetched_order{message} = "More than one Order returned. Invalid State: $total";
580     return \%fetched_order;
581   }
582
583   # 3. there is one valid order, try to import this one
584   if ($self->import_data_to_shop_order($content->{data}->[0])) {
585     %fetched_order = (shop_description => $self->config->description, number_of_orders => 1);
586   } else {
587     $fetched_order{message} = "Error: $@";
588     $fetched_order{error}   = 1;
589   }
590   return \%fetched_order;
591 }
592
593 sub get_new_orders {
594   my ($self) = @_;
595
596   my %fetched_order  = $self->get_fetched_order_structure;
597   my $assoc          = $self->all_open_orders();
598
599   # 1. fetch all open orders and try to import it as a kivi order
600   # 2. return the number of processed order $total
601   my $open_orders = $self->connector->POST('api/search/order', to_json($assoc));
602
603   # 1. check for bad request or connection problems
604   if ($open_orders->responseCode() != 200) {
605     $fetched_order{error}   = 1;
606     $fetched_order{message} = $open_orders->responseCode() . ' ' . $open_orders->responseContent();
607     return \%fetched_order;
608   }
609
610   # 1.1 parse json or exit
611   my $content;
612   try {
613     $content = from_json($open_orders->responseContent());
614   } catch {
615     $fetched_order{error}   = 1;
616     $fetched_order{message} = "Malformed JSON Data: $_ " . $open_orders->responseContent();
617     return \%fetched_order;
618   };
619
620   # 2. check if we found one or more order at all
621   my $total = $content->{total};
622   if ($total == 0) {
623     $fetched_order{number_of_orders} = 0;
624     return \%fetched_order;
625   } elsif (!$total || !($total > 0)) {
626     $fetched_order{error}   = 1;
627     $fetched_order{message} = "Undefined value for total orders returned. Invalid State: $total";
628     return \%fetched_order;
629   }
630
631   # 3. there are open orders. try to import one by one
632   $fetched_order{number_of_orders} = 0;
633   foreach my $open_order (@{ $content->{data} }) {
634     if ($self->import_data_to_shop_order($open_order)) {
635       $fetched_order{number_of_orders}++;
636     } else {
637       $fetched_order{message} .= "Error at importing order with running number:"
638                                   . $fetched_order{number_of_orders}+1 . ": $@ \n";
639       $fetched_order{error}    = 1;
640     }
641   }
642   return \%fetched_order;
643 }
644
645 sub get_article {
646   my ($self, $partnumber) = @_;
647
648   $partnumber   = $::form->escape($partnumber);
649   my $product_filter = {
650               'filter' => [
651                             {
652                               'value' => $partnumber,
653                               'type' => 'equals',
654                               'field' => 'productNumber'
655                             }
656                           ]
657                        };
658   my $ret = $self->connector->POST('api/search/product', to_json($product_filter));
659
660   my $response_code = $ret->responseCode();
661   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
662
663   my $data_json;
664   try {
665     $data_json = decode_json $ret->responseContent();
666   } catch {
667     die "Malformed JSON Data: $_ " . $ret->responseContent();
668   };
669
670   # maybe no product was found ...
671   return undef unless scalar @{ $data_json->{data} } > 0;
672   # caller wants this structure:
673   # $stock_onlineshop = $shop_article->{data}->{mainDetail}->{inStock};
674   # $active_online = $shop_article->{data}->{active};
675   my $data;
676   $data->{data}->{mainDetail}->{inStock} = $data_json->{data}->[0]->{stock};
677   $data->{data}->{active}                = $data_json->{data}->[0]->{active};
678   return $data;
679 }
680
681 sub get_version {
682   my ($self) = @_;
683
684   my $return  = {}; # return for caller
685   my $ret     = {}; # internal return
686
687   #  1. check if we can connect at all
688   #  2. request version number
689
690   $ret = $self->connector;
691   if (!defined $ret || 200 != $ret->responseCode()) {
692     $return->{success}         = 0;
693     $return->{data}->{version} = $self->{errors}; # whatever init puts in errors
694     return $return;
695   }
696
697   $ret = $self->connector->GET('api/_info/version');
698   if (200 == $ret->responseCode()) {
699     my $version = from_json($self->connector->responseContent())->{version};
700     $return->{success}         = 1;
701     $return->{data}->{version} = $version;
702   } else {
703     $return->{success}         = 0;
704     $return->{data}->{version} = $ret->responseContent(); # Whatever REST Interface says
705   }
706
707   return $return;
708 }
709
710 sub set_orderstatus {
711   my ($self, $order_id, $transition) = @_;
712
713   # one state differs
714   $transition = 'complete' if $transition eq 'completed';
715
716   croak "No shop order ID, should be in format [0-9a-f]{32}" unless $order_id   =~ m/^[0-9a-f]{32}$/;
717   croak "NO valid transition value"                          unless $transition =~ m/(open|process|cancel|complete)/;
718   my $ret;
719   $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
720   my $response_code = $ret->responseCode();
721   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
722
723 }
724 sub set_order_transaction_status {
725   my ($self, $ordnumber, $transition) = @_;
726
727   croak t8("No Order Number")         unless $ordnumber;
728   croak "NO valid transition value"   unless $transition =~ m/(paid)/;
729
730   # first fetch order_transaction id
731   my %fetched_order  = $self->get_fetched_order_structure;
732   my $assoc          = $self->all_open_orders();
733
734   # overwrite filter for exactly one ordnumber
735   $assoc->{filter}->[0]->{value} = $ordnumber;
736   $assoc->{filter}->[0]->{type}  = 'equals';
737   $assoc->{filter}->[0]->{field} = 'orderNumber';
738
739   # 1. fetch the order and import it as a kivi order
740   # 2. return the number of processed order (1)
741   my $one_order = $self->connector->POST('api/search/order', to_json($assoc));
742   # 1. check for bad request or connection problems
743   if ($one_order->responseCode() != 200) {
744     $fetched_order{error}   = 1;
745     $fetched_order{message} = $one_order->responseCode() . ' ' . $one_order->responseContent();
746     die "Invalid response code:" . $fetched_order{message};
747   }
748
749   # 1.1 parse json or exit
750   my $content;
751   try {
752     $content = from_json($one_order->responseContent());
753   } catch {
754     $fetched_order{error}   = 1;
755     $fetched_order{message} = "Malformed JSON Data: $_ " . $one_order->responseContent();
756     die "Invalid JSON Data:" . $fetched_order{message};
757   };
758
759   # 2. check if we found ONE order at all
760   my $total = $content->{total};
761   if ($total == 0) {
762     $fetched_order{number_of_orders} = 0;
763     die \%fetched_order;
764   } elsif ($total != 1) {
765     $fetched_order{error}   = 1;
766     $fetched_order{message} = "More than one Order returned. Invalid State: $total";
767     die "Invalid State:" . $fetched_order{message};
768   }
769   # we assume just one transaction at all
770   die "Can only sync one single Transaction " unless scalar @{ $content->{data}->[0]->{transactions} } == 1;
771
772   my $order_transaction_id = $content->{data}->[0]->{transactions}->[0]->{id};
773   my $ret;
774   $ret = $self->connector->POST("/api/_action/order_transaction/$order_transaction_id/state/$transition");
775
776   my $response_code = $ret->responseCode();
777   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
778
779 }
780
781 sub init_connector {
782   my ($self) = @_;
783
784   my $protocol = $self->config->server =~ /(^https:\/\/|^http:\/\/)/ ? '' : $self->config->protocol . '://';
785   my $client   = REST::Client->new(host => $protocol . $self->config->server);
786
787   $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
788   $client->addHeader('Content-Type', 'application/json');
789   $client->addHeader('charset',      'UTF-8');
790   $client->addHeader('Accept',       'application/json');
791
792   my %auth_req = (
793                    client_id     => $self->config->login,
794                    client_secret => $self->config->password,
795                    grant_type    => "client_credentials",
796                  );
797
798   my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
799
800   unless (200 == $ret->responseCode()) {
801     $self->{errors} .= $ret->responseContent();
802     return;
803   }
804
805   my $token = from_json($client->responseContent())->{access_token};
806   unless ($token) {
807     $self->{errors} .= "No Auth-Token received";
808     return;
809   }
810   # persist refresh token
811   $client->addHeader('Authorization' => 'Bearer ' . $token);
812   return $client;
813 }
814
815 sub import_data_to_shop_order {
816   my ($self, $import) = @_;
817
818   # failsafe checks for not yet implemented
819   die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
820
821   # no mapping unless we also have at least one shop order item ...
822   my $order_pos = delete $import->{lineItems};
823   croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
824
825   my $shop_order = $self->map_data_to_shoporder($import);
826
827   my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
828     $shop_order->save;
829     my $id = $shop_order->id;
830
831     my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
832     my $position = 0;
833     my $active_price_source = $self->config->price_source;
834     #Mapping Positions
835     my %discount_identifier;
836     foreach my $pos (@positions) {
837       if ($pos->{type} eq 'promotion') {
838         next unless $pos->{payload}->{discountType} eq 'percentage';
839         foreach my $discount_pos (@{ $pos->{payload}->{composition} }) {
840           $discount_identifier{$discount_pos->{id}} = { discount_percentage => $pos->{payload}->{value},
841                                                         discount_code       => $pos->{payload}->{code}   };
842         }
843         next;
844       }
845       $position++;
846       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
847       my %pos_columns = ( description          => $pos->{product}->{name},
848                           partnumber           => $pos->{product}->{productNumber},
849                           price                => $price,
850                           quantity             => $pos->{quantity},
851                           position             => $position,
852                           tax_rate             => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
853                           shop_trans_id        => $pos->{id}, # pos id or shop_trans_id ? or dont care?
854                           shop_order_id        => $id,
855                           active_price_source  => $active_price_source,
856                           identifier           => $pos->{identifier},
857                         );
858       my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
859       $pos_insert->save;
860     }
861     # add discount if percentage
862     while ((my $identifier, my $discount_ref) = each (%discount_identifier)) {
863       # load and update shop order position
864       my $soi = SL::DB::Manager::ShopOrderItem->find_by(identifier => $identifier);
865       die "No Shop Order Item for discount found! identfier: " . $identifier unless ref $soi eq 'SL::DB::ShopOrderItem';
866
867       $soi->update_attributes(discount      => $discount_ref->{discount_percentage} / 100,
868                               discount_code => $discount_ref->{discount_code}       );
869     }
870     $shop_order->positions($position);
871
872     if ( $self->config->shipping_costs_parts_id ) {
873       die t8("Not yet implemented");
874       # TODO NOT YET Implemented nor tested, this is shopware5 code:
875       my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
876       my %shipping_pos = ( description    => $import->{data}->{dispatch}->{name},
877                            partnumber     => $shipping_part->partnumber,
878                            price          => $import->{data}->{invoiceShipping},
879                            quantity       => 1,
880                            position       => $position,
881                            shop_trans_id  => 0,
882                            shop_order_id  => $id,
883                          );
884       my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
885       $shipping_pos_insert->save;
886     }
887
888     my $customer = $shop_order->get_customer;
889
890     if (ref $customer eq 'SL::DB::Customer') {
891       $shop_order->kivi_customer_id($customer->id);
892     }
893     $shop_order->save;
894
895     # update state in shopware before transaction ends
896     $self->set_orderstatus($shop_order->shop_trans_id, "process");
897
898     1;
899
900   }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
901                 $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
902 }
903
904 sub map_data_to_shoporder {
905   my ($self, $import) = @_;
906
907   croak "Expect a hash with one order." unless ref $import eq 'HASH';
908   # we need one number and a order date, some total prices and one customer
909   croak "Does not look like a shopware6 order" unless    $import->{orderNumber}
910                                                       && $import->{orderDateTime}
911                                                       && ref $import->{price} eq 'HASH'
912                                                       && ref $import->{orderCustomer} eq 'HASH';
913
914   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
915   die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
916
917   my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} }       @{ $import->{addresses} } ];
918   my $shipto_ary  = [ grep { $_->{id} == $shipto_id }                        @{ $import->{addresses} } ];
919   my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} }        @{ $import->{paymentMethods} } ];
920
921   die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
922           $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
923     unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
924
925   my $billing = $billing_ary->[0];
926   my $shipto  = $shipto_ary->[0];
927   # TODO payment info is not used at all
928   my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
929
930   # check mandatory fields from shopware
931   die t8("No billing city")   unless $billing->{city};
932   die t8("No shipto city")    unless $shipto->{city};
933   die t8("No customer email") unless $import->{orderCustomer}->{email};
934
935   # extract order date
936   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
937                                                locale    => 'de_DE',
938                                                time_zone => 'local'             );
939   my $orderdate;
940   try {
941     $orderdate = $parser->parse_datetime($import->{orderDateTime});
942   } catch { die "Cannot parse Order Date" . $_ };
943
944   my $shop_id      = $self->config->id;
945   my $tax_included = $self->config->pricetype;
946
947   # TODO copied from shopware5 connector
948   # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
949   my %payment_ids_methods = (
950     # shopware_paymentId => kivitendo_payment_id
951   );
952   my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
953   my $default_payment_id = $default_payment ? $default_payment->id : undef;
954   #
955
956
957   my %columns = (
958     amount                  => $import->{amountTotal},
959     billing_city            => $billing->{city},
960     billing_company         => $billing->{company},
961     billing_country         => $billing->{country}->{name},
962     billing_department      => $billing->{department},
963     billing_email           => $import->{orderCustomer}->{email},
964     billing_fax             => $billing->{fax},
965     billing_firstname       => $billing->{firstName},
966     #billing_greeting        => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
967     billing_lastname        => $billing->{lastName},
968     billing_phone           => $billing->{phone},
969     billing_street          => $billing->{street},
970     billing_vat             => $billing->{vatId},
971     billing_zipcode         => $billing->{zipcode},
972     customer_city           => $billing->{city},
973     customer_company        => $billing->{company},
974     customer_country        => $billing->{country}->{name},
975     customer_department     => $billing->{department},
976     customer_email          => $billing->{email},
977     customer_fax            => $billing->{fax},
978     customer_firstname      => $billing->{firstName},
979     #customer_greeting       => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
980     customer_lastname       => $billing->{lastName},
981     customer_phone          => $billing->{phoneNumber},
982     customer_street         => $billing->{street},
983     customer_vat            => $billing->{vatId},
984     customer_zipcode        => $billing->{zipcode},
985 #    customer_newsletter     => $customer}->{newsletter},
986     delivery_city           => $shipto->{city},
987     delivery_company        => $shipto->{company},
988     delivery_country        => $shipto->{country}->{name},
989     delivery_department     => $shipto->{department},
990     delivery_email          => "",
991     delivery_fax            => $shipto->{fax},
992     delivery_firstname      => $shipto->{firstName},
993     #delivery_greeting       => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
994     delivery_lastname       => $shipto->{lastName},
995     delivery_phone          => $shipto->{phone},
996     delivery_street         => $shipto->{street},
997     delivery_vat            => $shipto->{vatId},
998     delivery_zipcode        => $shipto->{zipcode},
999 #    host                    => $shop}->{hosts},
1000     netamount               => $import->{amountNet},
1001     order_date              => $orderdate,
1002     payment_description     => $payment->{name},
1003     payment_id              => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
1004     tax_included            => $tax_included eq "brutto" ? 1 : 0,
1005     shop_ordernumber        => $import->{orderNumber},
1006     shop_id                 => $shop_id,
1007     shop_trans_id           => $import->{id},
1008     # TODO map these:
1009     #remote_ip               => $import->{remoteAddress},
1010     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
1011     #sepa_bic                => $import->{paymentIntances}->{bic},
1012     #sepa_iban               => $import->{paymentIntances}->{iban},
1013     #shipping_costs          => $import->{invoiceShipping},
1014     #shipping_costs_net      => $import->{invoiceShippingNet},
1015     #shop_c_billing_id       => $import->{billing}->{customerId},
1016     #shop_c_billing_number   => $import->{billing}->{number},
1017     #shop_c_delivery_id      => $import->{shipping}->{id},
1018     #shop_customer_id        => $import->{customerId},
1019     #shop_customer_number    => $import->{billing}->{number},
1020     #shop_customer_comment   => $import->{customerComment},
1021   );
1022
1023   my $shop_order = SL::DB::ShopOrder->new(%columns);
1024   return $shop_order;
1025 }
1026
1027 sub _u8 {
1028   my ($value) = @_;
1029   return encode('UTF-8', $value // '');
1030 }
1031
1032 1;
1033
1034 __END__
1035
1036 =encoding utf-8
1037
1038 =head1 NAME
1039
1040   SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
1041
1042 =head1 SYNOPSIS
1043
1044
1045 =head1 DESCRIPTION
1046
1047 =head1 AVAILABLE METHODS
1048
1049 =over 4
1050
1051 =item C<get_one_order>
1052
1053 =item C<get_new_orders>
1054
1055 =item C<update_part>
1056
1057 Updates all metadata for a shop part. See base class for a general description.
1058 Specific Implementation notes:
1059 =over 4
1060
1061 =item Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
1062
1063 =item Checks if longdescription should be taken from part or shop_part
1064
1065 =item Checks if a language with the name 'Englisch' or template_code 'en'
1066       is available and sets the shopware6 'en-GB' locales for the product
1067
1068 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
1069
1070 The connecting key for shopware to kivi images is the image name.
1071 To get distinct entries the kivi partnumber is combined with the title (description)
1072 of the image. Therefore part1000_someTitlefromUser should be unique in
1073 Shopware.
1074 All image data is simply send to shopware whether or not image data
1075 has been edited recently.
1076 If set_cover is set, the image with the position 1 will be used as
1077 the shopware cover image.
1078 If delete_orphaned ist set, all images related to the shopware product
1079 which are not also in kivitendo will be deleted.
1080 Shopware (6.4.x) takes care of deleting all the relations if the media
1081 entry for the image is deleted.
1082 More on media and Shopware6 can be found here:
1083 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
1084
1085 =back
1086
1087 =over 4
1088
1089 =item C<get_article>
1090
1091 =item C<get_categories>
1092
1093 =item C<get_version>
1094
1095 Tries to establish a connection and in a second step
1096 tries to get the server's version number.
1097 Returns a hashref with the data structure the Base class expects.
1098
1099 =item C<set_orderstatus>
1100
1101 =item C<init_connector>
1102
1103 Inits the connection to the REST Server.
1104 Errors are collected in $self->{errors} and undef will be returned.
1105 If successful returns a REST::Client object for further communications.
1106
1107 =back
1108
1109 =head1 SEE ALSO
1110
1111 L<SL::ShopConnector::ALL>
1112
1113 =head1 BUGS
1114
1115 None yet. :)
1116
1117 =head1 TODOS
1118
1119 =over 4
1120
1121 =item * Map all data to shop_order
1122
1123 Missing fields are commented in the sub map_data_to_shoporder.
1124 Some items are SEPA debit info, IP adress, delivery costs etc
1125 Furthermore Shopware6 uses currency, country and locales information.
1126 Detailed list:
1127
1128     #customer_newsletter     => $customer}->{newsletter},
1129     #remote_ip               => $import->{remoteAddress},
1130     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
1131     #sepa_bic                => $import->{paymentIntances}->{bic},
1132     #sepa_iban               => $import->{paymentIntances}->{iban},
1133     #shipping_costs          => $import->{invoiceShipping},
1134     #shipping_costs_net      => $import->{invoiceShippingNet},
1135     #shop_c_billing_id       => $import->{billing}->{customerId},
1136     #shop_c_billing_number   => $import->{billing}->{number},
1137     #shop_c_delivery_id      => $import->{shipping}->{id},
1138     #shop_customer_id        => $import->{customerId},
1139     #shop_customer_number    => $import->{billing}->{number},
1140     #shop_customer_comment   => $import->{customerComment},
1141
1142 =item * Use shipping_costs_parts_id for additional shipping costs
1143
1144 Currently dies if a shipping_costs_parts_id is set in the config
1145
1146 =item * Payment Infos can be read from shopware but is not linked with kivi
1147
1148 Unused data structures in sub map_data_to_shoporder => payment_ary
1149
1150 =item * Delete orphaned images is new in this connector, but should be in a separate method
1151
1152 =item * Fetch from last order number is ignored and should not be needed
1153
1154 Fetch orders also sets the state of the order from open to process. The state setting
1155 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
1156 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
1157 and ignores any shopware order transition state.
1158
1159 =item * Get one order and get new orders is basically the same except for the filter
1160
1161 Right now the returning structure and the common parts of the filter are in two separate functions
1162
1163 =item * Locales!
1164
1165 Many error messages are thrown, but at least the more common cases should be localized.
1166
1167 =item * Multi language support
1168
1169 By guessing the correct german name for the english language some translation for parts can
1170 also be synced. This should be more clear (language configuration for shops) and the order
1171 synchronisation should also handle this (longdescription is simply copied from part.notes)
1172
1173 =item * Shopware6 Promotion Codes
1174
1175 Shopware6 Promotion Codes with discounts for specific positions (called line items in Shopware)
1176 are in a single line item of the type 'promotion'. kivitendo uses a simple real number in the
1177 range of 0 .. 1 to add a discount for a line item.
1178 This implementation adds a percentual discount for the correct positions and does not process
1179 the discount line item afterwards. The original Shopware Promotion Code is also saved.
1180
1181 =back
1182
1183 =head1 AUTHOR
1184
1185 Jan Büren jan@kivitendo.de
1186
1187 =cut