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