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