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