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