get_article, um Rückgabewert erweitert, falls kein Produkt gefunden wurde
[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
585   # maybe no product was found ...
586   return undef unless scalar @{ $data_json->{data} } > 0;
587   # caller wants this structure:
588   # $stock_onlineshop = $shop_article->{data}->{mainDetail}->{inStock};
589   # $active_online = $shop_article->{data}->{active};
590   my $data;
591   $data->{data}->{mainDetail}->{inStock} = $data_json->{data}->[0]->{stock};
592   $data->{data}->{active}                = $data_json->{data}->[0]->{active};
593   return $data;
594 }
595
596 sub get_version {
597   my ($self) = @_;
598
599   my $return  = {}; # return for caller
600   my $ret     = {}; # internal return
601
602   #  1. check if we can connect at all
603   #  2. request version number
604
605   $ret = $self->connector;
606   if (200 != $ret->responseCode()) {
607     $return->{success}         = 0;
608     $return->{data}->{version} = $self->{errors}; # whatever init puts in errors
609     return $return;
610   }
611
612   $ret = $self->connector->GET('api/_info/version');
613   if (200 == $ret->responseCode()) {
614     my $version = from_json($self->connector->responseContent())->{version};
615     $return->{success}         = 1;
616     $return->{data}->{version} = $version;
617   } else {
618     $return->{success}         = 0;
619     $return->{data}->{version} = $ret->responseContent(); # Whatever REST Interface says
620   }
621
622   return $return;
623 }
624
625 sub set_orderstatus {
626   my ($self, $order_id, $transition) = @_;
627
628   croak "No order ID, should be in format [0-9a-f]{32}" unless $order_id   =~ m/^[0-9a-f]{32}$/;
629   croak "NO valid transition value"                     unless $transition =~ m/(open|process|cancel|complete)/;
630   my $ret;
631   $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
632   my $response_code = $ret->responseCode();
633   die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code eq '200';
634
635 }
636
637 sub init_connector {
638   my ($self) = @_;
639
640   my $client = REST::Client->new(host => $self->config->server);
641   $client->addHeader('Content-Type', 'application/json');
642   $client->addHeader('charset',      'UTF-8');
643   $client->addHeader('Accept',       'application/json');
644
645   my %auth_req = (
646                    client_id     => $self->config->login,
647                    client_secret => $self->config->password,
648                    grant_type    => "client_credentials",
649                  );
650
651   my $ret = $client->POST('/api/oauth/token', encode_json(\%auth_req));
652
653   unless (200 == $ret->responseCode()) {
654     $self->{errors} .= $ret->responseContent();
655     return;
656   }
657
658   my $token = from_json($client->responseContent())->{access_token};
659   unless ($token) {
660     $self->{errors} .= "No Auth-Token received";
661     return;
662   }
663   # persist refresh token
664   $client->addHeader('Authorization' => 'Bearer ' . $token);
665   return $client;
666 }
667
668 sub import_data_to_shop_order {
669   my ($self, $import) = @_;
670
671   # failsafe checks for not yet implemented
672   die $::locale->text('Shipping cost article not implemented')          if $self->config->shipping_costs_parts_id;
673
674   # no mapping unless we also have at least one shop order item ...
675   my $order_pos = delete $import->{lineItems};
676   croak("No Order items fetched") unless ref $order_pos eq 'ARRAY';
677
678   my $shop_order = $self->map_data_to_shoporder($import);
679
680   my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
681     $shop_order->save;
682     my $id = $shop_order->id;
683
684     my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
685     my $position = 0;
686     my $active_price_source = $self->config->price_source;
687     #Mapping Positions
688     foreach my $pos (@positions) {
689       $position++;
690       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
691       my %pos_columns = ( description          => $pos->{product}->{description},
692                           partnumber           => $pos->{label},
693                           price                => $price,
694                           quantity             => $pos->{quantity},
695                           position             => $position,
696                           tax_rate             => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
697                           shop_trans_id        => $pos->{id}, # pos id or shop_trans_id ? or dont care?
698                           shop_order_id        => $id,
699                           active_price_source  => $active_price_source,
700                         );
701       my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
702       $pos_insert->save;
703     }
704     $shop_order->positions($position);
705
706     if ( $self->config->shipping_costs_parts_id ) {
707       die "Not yet implemented";
708       # TODO NOT YET Implemented nor tested, this is shopware5 code:
709       my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
710       my %shipping_pos = ( description    => $import->{data}->{dispatch}->{name},
711                            partnumber     => $shipping_part->partnumber,
712                            price          => $import->{data}->{invoiceShipping},
713                            quantity       => 1,
714                            position       => $position,
715                            shop_trans_id  => 0,
716                            shop_order_id  => $id,
717                          );
718       my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
719       $shipping_pos_insert->save;
720     }
721
722     my $customer = $shop_order->get_customer;
723
724     if (ref $customer eq 'SL::DB::Customer') {
725       $shop_order->kivi_customer_id($customer->id);
726     }
727     $shop_order->save;
728
729     # update state in shopware before transaction ends
730     $self->set_orderstatus($shop_order->shop_trans_id, "process");
731
732     1;
733
734   }) || die ('error while saving shop order ' . $shop_order->{shop_ordernumber} . 'Error: ' . $shop_order->db->error . "\n" .
735              'generic exception:' . $@);
736 }
737
738 sub map_data_to_shoporder {
739   my ($self, $import) = @_;
740
741   croak "Expect a hash with one order." unless ref $import eq 'HASH';
742   # we need one number and a order date, some total prices and one customer
743   croak "Does not look like a shopware6 order" unless    $import->{orderNumber}
744                                                       && $import->{orderDateTime}
745                                                       && ref $import->{price} eq 'HASH'
746                                                       && ref $import->{orderCustomer} eq 'HASH';
747
748   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
749   die "Cannot get shippingOrderAddressId for $import->{orderNumber}" unless $shipto_id;
750
751   my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} }       @{ $import->{addresses} } ];
752   my $shipto_ary  = [ grep { $_->{id} == $shipto_id }                        @{ $import->{addresses} } ];
753   my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} }        @{ $import->{paymentMethods} } ];
754
755   croak("No Billing and ship to address, for Order Number " . $import->{orderNumber} .
756         "ID Billing:" . $import->{billingAddressId} . " ID Shipping $shipto_id ")
757     unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
758
759   my $billing = $billing_ary->[0];
760   my $shipto  = $shipto_ary->[0];
761   # TODO payment info is not used at all
762   my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
763
764   croak "No billing city"   unless $billing->{city};
765   croak "No shipto city"    unless $shipto->{city};
766   croak "No customer email" unless $import->{orderCustomer}->{email};
767
768   # extract order date
769   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
770                                                locale    => 'de_DE',
771                                                time_zone => 'local'             );
772   my $orderdate;
773   try {
774     $orderdate = $parser->parse_datetime($import->{orderDateTime});
775   } catch { die "Cannot parse Order Date" . $_ };
776
777   my $shop_id      = $self->config->id;
778   my $tax_included = $self->config->pricetype;
779
780   # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
781   my %payment_ids_methods = (
782     # shopware_paymentId => kivitendo_payment_id
783   );
784   my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
785   my $default_payment_id = $default_payment ? $default_payment->id : undef;
786   #
787
788
789   my %columns = (
790     amount                  => $import->{amountTotal},
791     billing_city            => $billing->{city},
792     billing_company         => $billing->{company},
793     billing_country         => $billing->{country}->{name},
794     billing_department      => $billing->{department},
795     billing_email           => $import->{orderCustomer}->{email},
796     billing_fax             => $billing->{fax},
797     billing_firstname       => $billing->{firstName},
798     #billing_greeting        => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
799     billing_lastname        => $billing->{lastName},
800     billing_phone           => $billing->{phone},
801     billing_street          => $billing->{street},
802     billing_vat             => $billing->{vatId},
803     billing_zipcode         => $billing->{zipcode},
804     customer_city           => $billing->{city},
805     customer_company        => $billing->{company},
806     customer_country        => $billing->{country}->{name},
807     customer_department     => $billing->{department},
808     customer_email          => $billing->{email},
809     customer_fax            => $billing->{fax},
810     customer_firstname      => $billing->{firstName},
811     #customer_greeting       => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
812     customer_lastname       => $billing->{lastName},
813     customer_phone          => $billing->{phoneNumber},
814     customer_street         => $billing->{street},
815     customer_vat            => $billing->{vatId},
816     customer_zipcode        => $billing->{zipcode},
817 #    customer_newsletter     => $customer}->{newsletter},
818     delivery_city           => $shipto->{city},
819     delivery_company        => $shipto->{company},
820     delivery_country        => $shipto->{country}->{name},
821     delivery_department     => $shipto->{department},
822     delivery_email          => "",
823     delivery_fax            => $shipto->{fax},
824     delivery_firstname      => $shipto->{firstName},
825     #delivery_greeting       => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
826     delivery_lastname       => $shipto->{lastName},
827     delivery_phone          => $shipto->{phone},
828     delivery_street         => $shipto->{street},
829     delivery_vat            => $shipto->{vatId},
830     delivery_zipcode        => $shipto->{zipCode},
831 #    host                    => $shop}->{hosts},
832     netamount               => $import->{amountNet},
833     order_date              => $orderdate,
834     payment_description     => $payment->{name},
835     payment_id              => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
836     tax_included            => $tax_included eq "brutto" ? 1 : 0,
837     shop_ordernumber        => $import->{orderNumber},
838     shop_id                 => $shop_id,
839     shop_trans_id           => $import->{id},
840     # TODO map these:
841     #remote_ip               => $import->{remoteAddress},
842     #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
843     #sepa_bic                => $import->{paymentIntances}->{bic},
844     #sepa_iban               => $import->{paymentIntances}->{iban},
845     #shipping_costs          => $import->{invoiceShipping},
846     #shipping_costs_net      => $import->{invoiceShippingNet},
847     #shop_c_billing_id       => $import->{billing}->{customerId},
848     #shop_c_billing_number   => $import->{billing}->{number},
849     #shop_c_delivery_id      => $import->{shipping}->{id},
850     #shop_customer_id        => $import->{customerId},
851     #shop_customer_number    => $import->{billing}->{number},
852     #shop_customer_comment   => $import->{customerComment},
853   );
854
855   my $shop_order = SL::DB::ShopOrder->new(%columns);
856   return $shop_order;
857 }
858
859 sub _u8 {
860   my ($value) = @_;
861   return encode('UTF-8', $value // '');
862 }
863
864 1;
865
866 __END__
867
868 =encoding utf-8
869
870 =head1 NAME
871
872   SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
873
874 =head1 SYNOPSIS
875
876
877 =head1 DESCRIPTION
878
879 =head1 AVAILABLE METHODS
880
881 =over 4
882
883 =item C<get_one_order>
884
885 =item C<get_new_orders>
886
887 =item C<update_part>
888
889 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
890
891 The important key for shopware is the image name. To get distinct
892 entries the kivi partnumber is combined with the title (description)
893 of the image. Therefore part1000_someTitlefromUser should be unique in
894 Shopware.
895 All image data is simply send to shopware whether or not image data
896 has been edited recently.
897 If set_cover is set, the image with the position 1 will be used as
898 the shopware cover image.
899 If delete_orphaned ist set, all images related to the shopware product
900 which are not also in kivitendo will be deleted.
901 Shopware (6.4.x) takes care of deleting all the relations if the media
902 entry for the image is deleted.
903 More on media and Shopware6 can be found here:
904 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
905
906
907 =item C<get_article>
908
909 =item C<get_categories>
910
911 =item C<get_version>
912
913 Tries to establish a connection and in a second step
914 tries to get the server's version number.
915 Returns a hashref with the data structure the Base class expects.
916
917 =item C<set_orderstatus>
918
919 =item C<init_connector>
920
921 Inits the connection to the REST Server.
922 Errors are collected in $self->{errors} and undef will be returned.
923 If successful returns a REST::Client object for further communications.
924
925 =back
926
927 =head1 SEE ALSO
928
929 L<SL::ShopConnector::ALL>
930
931 =head1 BUGS
932
933 None yet. :)
934
935 =head1 TODOS
936
937 =over 4
938
939 =item * Map all data to shop_order
940
941 Missing fields are commented in the sub map_data_to_shoporder.
942 Some items are SEPA debit info, IP adress, delivery costs etc
943 Furthermore Shopware6 uses currency, country and locales information.
944
945 =item * Use shipping_costs_parts_id for additional shipping costs
946
947 Currently dies if a shipping_costs_parts_id is set in the config
948
949 =item * Payment Infos can be read from shopware but is not linked with kivi
950
951 Unused data structures in sub map_data_to_shoporder => payment_ary
952
953 =item * Delete orphaned images is new in this connector, but should be in a separate method
954
955 =item * Fetch from last order number is ignored and should not be needed
956
957 Fetch orders also sets the state of the order from open to process. The state setting
958 is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
959 at all. Nevertheless get_one_order just gets one order with the exactly matching order number
960 and ignores any shopware order transition state.
961
962 =item * Get one order and get new orders is basically the same except for the filter
963
964 Right now the returning structure and the common parts of the filter are in two separate functions
965
966 =item * Locales!
967
968 Many error messages are thrown, but at least the more common cases should be localized.
969
970 =back
971
972 =head1 AUTHOR
973
974 Jan Büren jan@kivitendo.de
975
976 =cut