Projekt

Allgemein

Profil

« Zurück | Weiter » 

Revision 412a13b4

Von Jan Büren vor fast 3 Jahren hinzugefügt

  • ID 412a13b46cae5d4d6dc9c15f8938448d98ea9ae5
  • Vorgänger 499b6f5c
  • Nachfolger fba387a4

Shopware6 Connector. Initiale Version

TODOS s.a. POD

Unterschiede anzeigen:

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
# ua multiple form-data
665
sub init_ua {
666
  my ($self) = @_;
667

  
668
  my $section = 'bekuplast_elo';
669
  my $config  = $::lx_office_conf{$section} || {};
670
  my $client;
671
  # mandatory config parameters
672
  foreach (qw(user pass apikey)) {
673
    die "parameter '$_' in section '$section' must be set in config file" if !defined $config->{$_};
674
  }
675
  $client = REST::Client->new(host => $self->host);
676
  # no test case available in ELO API
677
  # and set ua we need this, because ELO wants multippart/form-data
678
  my $ua = $self->client->getUseragent();
679

  
680
  $ua->default_header(Authorization  => 'Basic ' . encode_base64($config->{user} . ':' . $config->{pass}),
681
                      apikey         => $config->{apikey},
682
                      'Content-Type' => 'multipart/form-data',
683
                     );
684

  
685
  return $ua;
686
}
687

  
688

  
689
sub import_data_to_shop_order {
690
  my ($self, $import) = @_;
691

  
692
  # failsafe checks for not yet implemented
693
  die $::locale->text('Shipping cost article not implemented')          if $self->config->shipping_costs_parts_id;
694

  
695
  # no mapping unless we also have at least one shop order item ...
696
  my $order_pos = delete $import->{lineItems};
697
  croak("No Order items fetched") unless ref $order_pos eq 'ARRAY';
698

  
699
  my $shop_order = $self->map_data_to_shoporder($import);
700

  
701
  my $shop_transaction_ret = $shop_order->db->with_transaction(sub {
702
    $shop_order->save;
703
    my $id = $shop_order->id;
704

  
705
    my @positions = sort { Sort::Naturally::ncmp($a->{"label"}, $b->{"label"}) } @{ $order_pos };
706
    my $position = 0;
707
    my $active_price_source = $self->config->price_source;
708
    #Mapping Positions
709
    foreach my $pos (@positions) {
710
      $position++;
711
      my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
712
      my %pos_columns = ( description          => $pos->{product}->{description},
713
                          partnumber           => $pos->{label},
714
                          price                => $price,
715
                          quantity             => $pos->{quantity},
716
                          position             => $position,
717
                          tax_rate             => $pos->{priceDefinition}->{taxRules}->[0]->{taxRate},
718
                          shop_trans_id        => $pos->{id}, # pos id or shop_trans_id ? or dont care?
719
                          shop_order_id        => $id,
720
                          active_price_source  => $active_price_source,
721
                        );
722
      my $pos_insert = SL::DB::ShopOrderItem->new(%pos_columns);
723
      $pos_insert->save;
724
    }
725
    $shop_order->positions($position);
726

  
727
    if ( $self->config->shipping_costs_parts_id ) {
728
      die "Not yet implemented";
729
      # TODO NOT YET Implemented nor tested, this is shopware5 code:
730
      my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
731
      my %shipping_pos = ( description    => $import->{data}->{dispatch}->{name},
732
                           partnumber     => $shipping_part->partnumber,
733
                           price          => $import->{data}->{invoiceShipping},
734
                           quantity       => 1,
735
                           position       => $position,
736
                           shop_trans_id  => 0,
737
                           shop_order_id  => $id,
738
                         );
739
      my $shipping_pos_insert = SL::DB::ShopOrderItem->new(%shipping_pos);
740
      $shipping_pos_insert->save;
741
    }
742

  
743
    my $customer = $shop_order->get_customer;
744

  
745
    if (ref $customer eq 'SL::DB::Customer') {
746
      $shop_order->kivi_customer_id($customer->id);
747
    }
748
    $shop_order->save;
749

  
750
    # update state in shopware before transaction ends
751
    $self->set_orderstatus($shop_order->shop_trans_id, "process");
752

  
753
    1;
754

  
755
  }) || die ('error while saving shop order ' . $shop_order->{shop_ordernumber} . 'Error: ' . $shop_order->db->error . "\n" .
756
             'generic exception:' . $@);
757
}
758

  
759
sub map_data_to_shoporder {
760
  my ($self, $import) = @_;
761

  
762
  croak "Expect a hash with one order." unless ref $import eq 'HASH';
763
  # we need one number and a order date, some total prices and one customer
764
  croak "Does not look like a shopware6 order" unless    $import->{orderNumber}
765
                                                      && $import->{orderDateTime}
766
                                                      && ref $import->{price} eq 'HASH'
767
                                                      && ref $import->{orderCustomer} eq 'HASH';
768

  
769
  my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
770
  die "Cannot get shippingOrderAddressId for $import->{orderNumber}" unless $shipto_id;
771

  
772
  my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} }       @{ $import->{addresses} } ];
773
  my $shipto_ary  = [ grep { $_->{id} == $shipto_id }                        @{ $import->{addresses} } ];
774
  my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} }        @{ $import->{paymentMethods} } ];
775

  
776
  croak("No Billing and ship to address, for Order Number " . $import->{orderNumber} .
777
        "ID Billing:" . $import->{billingAddressId} . " ID Shipping $shipto_id ")
778
    unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
779

  
780
  my $billing = $billing_ary->[0];
781
  my $shipto  = $shipto_ary->[0];
782
  # TODO payment info is not used at all
783
  my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
784

  
785
  croak "No billing city"   unless $billing->{city};
786
  croak "No shipto city"    unless $shipto->{city};
787
  croak "No customer email" unless $import->{orderCustomer}->{email};
788

  
789
  # extract order date
790
  my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
791
                                               locale    => 'de_DE',
792
                                               time_zone => 'local'             );
793
  my $orderdate;
794
  try {
795
    $orderdate = $parser->parse_datetime($import->{orderDateTime});
796
  } catch { die "Cannot parse Order Date" . $_ };
797

  
798
  my $shop_id      = $self->config->id;
799
  my $tax_included = $self->config->pricetype;
800

  
801
  # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
802
  my %payment_ids_methods = (
803
    # shopware_paymentId => kivitendo_payment_id
804
  );
805
  my $default_payment    = SL::DB::Manager::PaymentTerm->get_first();
806
  my $default_payment_id = $default_payment ? $default_payment->id : undef;
807
  #
808

  
809

  
810
  my %columns = (
811
    amount                  => $import->{amountTotal},
812
    billing_city            => $billing->{city},
813
    billing_company         => $billing->{company},
814
    billing_country         => $billing->{country}->{name},
815
    billing_department      => $billing->{department},
816
    billing_email           => $import->{orderCustomer}->{email},
817
    billing_fax             => $billing->{fax},
818
    billing_firstname       => $billing->{firstName},
819
    #billing_greeting        => ($import->{billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
820
    billing_lastname        => $billing->{lastName},
821
    billing_phone           => $billing->{phone},
822
    billing_street          => $billing->{street},
823
    billing_vat             => $billing->{vatId},
824
    billing_zipcode         => $billing->{zipcode},
825
    customer_city           => $billing->{city},
826
    customer_company        => $billing->{company},
827
    customer_country        => $billing->{country}->{name},
828
    customer_department     => $billing->{department},
829
    customer_email          => $billing->{email},
830
    customer_fax            => $billing->{fax},
831
    customer_firstname      => $billing->{firstName},
832
    #customer_greeting       => ($billing}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
833
    customer_lastname       => $billing->{lastName},
834
    customer_phone          => $billing->{phoneNumber},
835
    customer_street         => $billing->{street},
836
    customer_vat            => $billing->{vatId},
837
    customer_zipcode        => $billing->{zipcode},
838
#    customer_newsletter     => $customer}->{newsletter},
839
    delivery_city           => $shipto->{city},
840
    delivery_company        => $shipto->{company},
841
    delivery_country        => $shipto->{country}->{name},
842
    delivery_department     => $shipto->{department},
843
    delivery_email          => "",
844
    delivery_fax            => $shipto->{fax},
845
    delivery_firstname      => $shipto->{firstName},
846
    #delivery_greeting       => ($shipto}->{salutation} eq 'mr' ? 'Herr' : 'Frau'),
847
    delivery_lastname       => $shipto->{lastName},
848
    delivery_phone          => $shipto->{phone},
849
    delivery_street         => $shipto->{street},
850
    delivery_vat            => $shipto->{vatId},
851
    delivery_zipcode        => $shipto->{zipCode},
852
#    host                    => $shop}->{hosts},
853
    netamount               => $import->{amountNet},
854
    order_date              => $orderdate,
855
    payment_description     => $payment->{name},
856
    payment_id              => $payment_ids_methods{$import->{paymentId}} || $default_payment_id,
857
    tax_included            => $tax_included eq "brutto" ? 1 : 0,
858
    shop_ordernumber        => $import->{orderNumber},
859
    shop_id                 => $shop_id,
860
    shop_trans_id           => $import->{id},
861
    # TODO map these:
862
    #remote_ip               => $import->{remoteAddress},
863
    #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
864
    #sepa_bic                => $import->{paymentIntances}->{bic},
865
    #sepa_iban               => $import->{paymentIntances}->{iban},
866
    #shipping_costs          => $import->{invoiceShipping},
867
    #shipping_costs_net      => $import->{invoiceShippingNet},
868
    #shop_c_billing_id       => $import->{billing}->{customerId},
869
    #shop_c_billing_number   => $import->{billing}->{number},
870
    #shop_c_delivery_id      => $import->{shipping}->{id},
871
    #shop_customer_id        => $import->{customerId},
872
    #shop_customer_number    => $import->{billing}->{number},
873
    #shop_customer_comment   => $import->{customerComment},
874
  );
875

  
876
  my $shop_order = SL::DB::ShopOrder->new(%columns);
877
  return $shop_order;
878
}
879

  
880
sub _u8 {
881
  my ($value) = @_;
882
  return encode('UTF-8', $value // '');
883
}
884

  
885
1;
886

  
887
__END__
888

  
889
=encoding utf-8
890

  
891
=head1 NAME
892

  
893
  SL::ShopConnector::Shopware6 - this is the Connector Class for Shopware 6
894

  
895
=head1 SYNOPSIS
896

  
897

  
898
=head1 DESCRIPTION
899

  
900
=head1 AVAILABLE METHODS
901

  
902
=over 4
903

  
904
=item C<get_one_order>
905

  
906
=item C<get_new_orders>
907

  
908
=item C<update_part>
909

  
910
=item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
911

  
912
The important key for shopware is the image name. To get distinct
913
entries the kivi partnumber is combined with the title (description)
914
of the image. Therefore part1000_someTitlefromUser should be unique in
915
Shopware.
916
All image data is simply send to shopware whether or not image data
917
has been edited recently.
918
If set_cover is set, the image with the position 1 will be used as
919
the shopware cover image.
920
If delete_orphaned ist set, all images related to the shopware product
921
which are not also in kivitendo will be deleted.
922
Shopware (6.4.x) takes care of deleting all the relations if the media
923
entry for the image is deleted.
924
More on media and Shopware6 can be found here:
925
https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
926

  
927

  
928
=item C<get_article>
929

  
930
=item C<get_categories>
931

  
932
=item C<get_version>
933

  
934
Tries to establish a connection and in a second step
935
tries to get the server's version number.
936
Returns a hashref with the data structure the Base class expects.
937

  
938
=item C<set_orderstatus>
939

  
940
=item C<init_connector>
941

  
942
Inits the connection to the REST Server.
943
Errors are collected in $self->{errors} and undef will be returned.
944
If successful returns a REST::Client object for further communications.
945

  
946
=back
947

  
948
=head1 SEE ALSO
949

  
950
L<SL::ShopConnector::ALL>
951

  
952
=head1 BUGS
953

  
954
None yet. :)
955

  
956
=head1 TODOS
957

  
958
=over 4
959

  
960
=item * Map all data to shop_order
961

  
962
Missing fields are commented in the sub map_data_to_shoporder.
963
Some items are SEPA debit info, IP adress, delivery costs etc
964
Furthermore Shopware6 uses currency, country and locales information.
965

  
966
=item * Use shipping_costs_parts_id for additional shipping costs
967

  
968
Currently dies if a shipping_costs_parts_id is set in the config
969

  
970
=item * Payment Infos can be read from shopware but is not linked with kivi
971

  
972
Unused data structures in sub map_data_to_shoporder => payment_ary
973

  
974
=item * Delete orphaned images is new in this connector, but should be in a separate method
975

  
976
=item * Fetch from last order number is ignored and should not be needed
977

  
978
Fetch orders also sets the state of the order from open to process. The state setting
979
is transaction safe and therefore get_new_orders should always fetch only unprocessed orders
980
at all. Nevertheless get_one_order just gets one order with the exactly matching order number
981
and ignores any shopware order transition state.
982

  
983
=item * Get one order and get new orders is basically the same except for the filter
984

  
985
Right now the returning structure and the common parts of the filter are in two separate functions
986

  
987
=item * Locales!
988

  
989
Many error messages are thrown, but at least the more common cases should be localized.
990

  
991
=back
992

  
993
=head1 AUTHOR
994

  
995
Jan Büren jan@kivitendo.de
996

  
997
=cut

Auch abrufbar als: Unified diff