Shopware6: Status completed innerhalb des Konnektors mappen
[kivitendo-erp.git] / SL / ShopConnector / Shopware6.pm
index e065176..6d04a1b 100644 (file)
@@ -6,11 +6,13 @@ use parent qw(SL::ShopConnector::Base);
 
 use Carp;
 use Encode qw(encode);
+use List::Util qw(first);
 use REST::Client;
 use Try::Tiny;
 
 use SL::JSON;
 use SL::Helper::Flash;
+use SL::Locale::String qw(t8);
 
 use Rose::Object::MakeMethods::Generic (
   'scalar --get_set_init' => [ qw(connector) ],
@@ -105,21 +107,12 @@ sub update_part {
   my ($self, $shop_part, $todo) = @_;
 
   #shop_part is passed as a param
-  croak "Need a valid Shop Part for updating Part" unless ref($shop_part) eq 'SL::DB::ShopPart';
-  croak "Invalid todo for updating Part"           unless $todo =~ m/(price|stock|price_stock|active|all)/;
+  croak t8("Need a valid Shop Part for updating Part") unless ref($shop_part) eq 'SL::DB::ShopPart';
+  croak t8("Invalid todo for updating Part")           unless $todo =~ m/(price|stock|price_stock|active|all)/;
 
   my $part = SL::DB::Part->new(id => $shop_part->part_id)->load;
   die "Shop Part but no kivi Part?" unless ref $part eq 'SL::DB::Part';
 
-  my @cat = ();
-  # if the part is connected to a category at all
-  if ($shop_part->shop_category) {
-    foreach my $row_cat ( @{ $shop_part->shop_category } ) {
-      my $temp = { ( id => @{$row_cat}[0] ) };
-      push ( @cat, $temp );
-    }
-  }
-
   my $tax_n_price = $shop_part->get_tax_and_price;
   my $price       = $tax_n_price->{price};
   my $taxrate     = $tax_n_price->{tax};
@@ -136,7 +129,22 @@ sub update_part {
 
   my $update_p;
   $update_p->{productNumber} = $part->partnumber;
-  $update_p->{name}          = $part->description;
+  $update_p->{name}          = _u8($part->description);
+  $update_p->{description}   =   $shop_part->shop->use_part_longdescription
+                               ? _u8($part->notes)
+                               : _u8($shop_part->shop_description);
+
+  # locales simple check for english
+  my $english = SL::DB::Manager::Language->get_first(query => [ description   => { ilike => 'Englisch' },
+                                                        or => [ template_code => { ilike => 'en' } ],
+                                                    ]);
+  if (ref $english eq 'SL::DB::Language') {
+    # add english translation for product
+    # TODO (or not): No Translations for shop_part->shop_description available
+    my $translation = first { $english->id == $_->language_id } @{ $part->translations };
+    $update_p->{translations}->{'en-GB'}->{name}        = _u8($translation->{translation});
+    $update_p->{translations}->{'en-GB'}->{description} = _u8($translation->{longdescription});
+  }
 
   $update_p->{stock}  = $::form->round_amount($part->onhand, 0) if ($todo =~ m/(stock|all)/);
   # JSON::true JSON::false
@@ -172,7 +180,11 @@ sub update_part {
     }
     undef $update_p->{partNumber}; # we dont need this one
     $ret = $self->connector->PATCH('api/product/' . $one_d->{id}, to_json($update_p));
-    die "Updating part with " .  $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
+    unless (204 == $ret->responseCode()) {
+      die t8('Part Description is too long for this Shopware version. It should have lower than 255 characters.')
+         if $ret->responseContent() =~ m/Diese Zeichenkette ist zu lang. Sie sollte.*255 Zeichen/;
+      die "Updating part with " .  $part->partnumber . " failed: " . $ret->responseContent() unless (204 == $ret->responseCode());
+    }
   } else {
     # create part
     # 1. get the correct tax for this product
@@ -223,8 +235,72 @@ sub update_part {
     $self->sync_all_images(shop_part => $shop_part, set_cover => 1, delete_orphaned => 1);
   } catch { die "Could not sync images for Part " . $part->partnumber . " Reason: $_" };
 
+  # if there are categories try to sync this with the shop_part
+  try {
+    $self->sync_all_categories(shop_part => $shop_part);
+  } catch { die "Could not sync Categories for Part " . $part->partnumber . " Reason: $_" };
+
   return 1; # no invalid response code -> success
 }
+sub sync_all_categories {
+  my ($self, %params) = @_;
+
+  my $shop_part = delete $params{shop_part};
+  croak "Need a valid Shop Part for updating Images" unless ref($shop_part) eq 'SL::DB::ShopPart';
+
+  my $partnumber = $shop_part->part->partnumber;
+  die "Shop Part but no kivi Partnumber" unless $partnumber;
+
+  my ($ret, $response_code);
+  # 1 get  uuid for product
+  my $product_filter = {
+          'filter' => [
+                        {
+                          'value' => $partnumber,
+                          'type'  => 'equals',
+                          'field' => 'productNumber'
+                        }
+                      ]
+    };
+
+  $ret = $self->connector->POST('api/search/product', to_json($product_filter));
+  $response_code = $ret->responseCode();
+  die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 200;
+  my ($product_id, $category_tree);
+  try {
+    $product_id    = from_json($ret->responseContent())->{data}->[0]->{id};
+    $category_tree = from_json($ret->responseContent())->{data}->[0]->{categoryIds};
+  } catch { die "Malformed JSON Data: $_ " . $ret->responseContent();  };
+  my $cat;
+  # if the part is connected to a category at all
+  if ($shop_part->shop_category) {
+    foreach my $row_cat (@{ $shop_part->shop_category }) {
+      $cat->{@{ $row_cat }[0]} = @{ $row_cat }[1];
+    }
+  }
+  # delete
+  foreach my $shopware_cat (@{ $category_tree }) {
+    if ($cat->{$shopware_cat}) {
+      # cat exists and no delete
+      delete $cat->{$shopware_cat};
+      next;
+    }
+    # cat exists and delete
+    $ret = $self->connector->DELETE("api/product/$product_id/categories/$shopware_cat");
+    $response_code = $ret->responseCode();
+    die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
+  }
+  # now add only new categories
+  my $p;
+  $p->{id}  = $product_id;
+  $p->{categories} = ();
+  foreach my $new_cat (keys %{ $cat }) {
+    push @{ $p->{categories} }, {id => $new_cat};
+  }
+    $ret = $self->connector->PATCH("api/product/$product_id", to_json($p));
+    $response_code = $ret->responseCode();
+    die "Request failed, response code was: $response_code\n" . $ret->responseContent() unless $response_code == 204;
+}
 
 sub sync_all_images {
   my ($self, %params) = @_;
@@ -453,7 +529,7 @@ sub get_categories {
 sub get_one_order  {
   my ($self, $ordnumber) = @_;
 
-  die "No ordnumber" unless $ordnumber;
+  croak t8("No Order Number") unless $ordnumber;
   # set known params for the return structure
   my %fetched_order  = $self->get_fetched_order_structure;
   my $assoc          = $self->all_open_orders();
@@ -581,6 +657,9 @@ sub get_article {
   } catch {
     die "Malformed JSON Data: $_ " . $ret->responseContent();
   };
+
+  # maybe no product was found ...
+  return undef unless scalar @{ $data_json->{data} } > 0;
   # caller wants this structure:
   # $stock_onlineshop = $shop_article->{data}->{mainDetail}->{inStock};
   # $active_online = $shop_article->{data}->{active};
@@ -622,8 +701,11 @@ sub get_version {
 sub set_orderstatus {
   my ($self, $order_id, $transition) = @_;
 
-  croak "No order ID, should be in format [0-9a-f]{32}" unless $order_id   =~ m/^[0-9a-f]{32}$/;
-  croak "NO valid transition value"                     unless $transition =~ m/(open|process|cancel|complete)/;
+  # one state differs
+  $transition = 'complete' if $transition eq 'completed';
+
+  croak "No shop order ID, should be in format [0-9a-f]{32}" unless $order_id   =~ m/^[0-9a-f]{32}$/;
+  croak "NO valid transition value"                          unless $transition =~ m/(open|process|cancel|complete)/;
   my $ret;
   $ret = $self->connector->POST("/api/_action/order/$order_id/state/$transition");
   my $response_code = $ret->responseCode();
@@ -634,7 +716,10 @@ sub set_orderstatus {
 sub init_connector {
   my ($self) = @_;
 
-  my $client = REST::Client->new(host => $self->config->server);
+  my $protocol = $self->config->server =~ /(^https:\/\/|^http:\/\/)/ ? '' : $self->config->protocol . '://';
+  my $client   = REST::Client->new(host => $protocol . $self->config->server);
+
+  $client->getUseragent()->proxy([$self->config->protocol], $self->config->proxy) if $self->config->proxy;
   $client->addHeader('Content-Type', 'application/json');
   $client->addHeader('charset',      'UTF-8');
   $client->addHeader('Accept',       'application/json');
@@ -666,11 +751,11 @@ sub import_data_to_shop_order {
   my ($self, $import) = @_;
 
   # failsafe checks for not yet implemented
-  die $::locale->text('Shipping cost article not implemented')          if $self->config->shipping_costs_parts_id;
+  die t8('Shipping cost article not implemented') if $self->config->shipping_costs_parts_id;
 
   # no mapping unless we also have at least one shop order item ...
   my $order_pos = delete $import->{lineItems};
-  croak("No Order items fetched") unless ref $order_pos eq 'ARRAY';
+  croak t8("No Order items fetched") unless ref $order_pos eq 'ARRAY';
 
   my $shop_order = $self->map_data_to_shoporder($import);
 
@@ -685,8 +770,8 @@ sub import_data_to_shop_order {
     foreach my $pos (@positions) {
       $position++;
       my $price       = $::form->round_amount($pos->{unitPrice}, 2); # unit
-      my %pos_columns = ( description          => $pos->{product}->{description},
-                          partnumber           => $pos->{label},
+      my %pos_columns = ( description          => $pos->{product}->{name},
+                          partnumber           => $pos->{product}->{productNumber},
                           price                => $price,
                           quantity             => $pos->{quantity},
                           position             => $position,
@@ -701,7 +786,7 @@ sub import_data_to_shop_order {
     $shop_order->positions($position);
 
     if ( $self->config->shipping_costs_parts_id ) {
-      die "Not yet implemented";
+      die t8("Not yet implemented");
       # TODO NOT YET Implemented nor tested, this is shopware5 code:
       my $shipping_part = SL::DB::Part->find_by( id => $self->config->shipping_costs_parts_id);
       my %shipping_pos = ( description    => $import->{data}->{dispatch}->{name},
@@ -728,8 +813,8 @@ sub import_data_to_shop_order {
 
     1;
 
-  }) || die ('error while saving shop order ' . $shop_order->{shop_ordernumber} . 'Error: ' . $shop_order->db->error . "\n" .
-             'generic exception:' . $@);
+  }) || die t8('Error while saving shop order #1. DB Error #2. Generic exception #3.',
+                $shop_order->{shop_ordernumber}, $shop_order->db->error, $@);
 }
 
 sub map_data_to_shoporder {
@@ -743,14 +828,14 @@ sub map_data_to_shoporder {
                                                       && ref $import->{orderCustomer} eq 'HASH';
 
   my $shipto_id = $import->{deliveries}->[0]->{shippingOrderAddressId};
-  die "Cannot get shippingOrderAddressId for $import->{orderNumber}" unless $shipto_id;
+  die t8("Cannot get shippingOrderAddressId for #1", $import->{orderNumber}) unless $shipto_id;
 
   my $billing_ary = [ grep { $_->{id} == $import->{billingAddressId} }       @{ $import->{addresses} } ];
   my $shipto_ary  = [ grep { $_->{id} == $shipto_id }                        @{ $import->{addresses} } ];
   my $payment_ary = [ grep { $_->{id} == $import->{paymentMethodId} }        @{ $import->{paymentMethods} } ];
 
-  croak("No Billing and ship to address, for Order Number " . $import->{orderNumber} .
-        "ID Billing:" . $import->{billingAddressId} . " ID Shipping $shipto_id ")
+  die t8("No Billing and ship to address, for Order Number #1 with ID Billing #2 and ID Shipping #3",
+          $import->{orderNumber}, $import->{billingAddressId}, $import->{deliveries}->[0]->{shippingOrderAddressId})
     unless scalar @{ $billing_ary } == 1 && scalar @{ $shipto_ary } == 1;
 
   my $billing = $billing_ary->[0];
@@ -758,9 +843,10 @@ sub map_data_to_shoporder {
   # TODO payment info is not used at all
   my $payment = scalar @{ $payment_ary } ? delete $payment_ary->[0] : undef;
 
-  croak "No billing city"   unless $billing->{city};
-  croak "No shipto city"    unless $shipto->{city};
-  croak "No customer email" unless $import->{orderCustomer}->{email};
+  # check mandatory fields from shopware
+  die t8("No billing city")   unless $billing->{city};
+  die t8("No shipto city")    unless $shipto->{city};
+  die t8("No customer email") unless $import->{orderCustomer}->{email};
 
   # extract order date
   my $parser = DateTime::Format::Strptime->new(pattern   => '%Y-%m-%dT%H:%M:%S',
@@ -774,6 +860,7 @@ sub map_data_to_shoporder {
   my $shop_id      = $self->config->id;
   my $tax_included = $self->config->pricetype;
 
+  # TODO copied from shopware5 connector
   # Mapping Zahlungsmethoden muss an Firmenkonfiguration angepasst werden
   my %payment_ids_methods = (
     # shopware_paymentId => kivitendo_payment_id
@@ -883,10 +970,21 @@ __END__
 
 =item C<update_part>
 
+Updates all metadata for a shop part. See base class for a general description.
+Specific Implementation notes:
+=over 4
+
+=item Calls sync_all_images with set_cover = 1 and delete_orphaned = 1
+
+=item Checks if longdescription should be taken from part or shop_part
+
+=item Checks if a language with the name 'Englisch' or template_code 'en'
+      is available and sets the shopware6 'en-GB' locales for the product
+
 =item C<sync_all_images (set_cover: 0|1, delete_orphaned: 0|1)>
 
-The important key for shopware is the image name. To get distinct
-entries the kivi partnumber is combined with the title (description)
+The connecting key for shopware to kivi images is the image name.
+To get distinct entries the kivi partnumber is combined with the title (description)
 of the image. Therefore part1000_someTitlefromUser should be unique in
 Shopware.
 All image data is simply send to shopware whether or not image data
@@ -900,6 +998,9 @@ entry for the image is deleted.
 More on media and Shopware6 can be found here:
 https://shopware.stoplight.io/docs/admin-api/ZG9jOjEyNjI1Mzkw-media-handling
 
+=back
+
+=over 4
 
 =item C<get_article>
 
@@ -938,6 +1039,21 @@ None yet. :)
 Missing fields are commented in the sub map_data_to_shoporder.
 Some items are SEPA debit info, IP adress, delivery costs etc
 Furthermore Shopware6 uses currency, country and locales information.
+Detailed list:
+
+    #customer_newsletter     => $customer}->{newsletter},
+    #remote_ip               => $import->{remoteAddress},
+    #sepa_account_holder     => $import->{paymentIntances}->{accountHolder},
+    #sepa_bic                => $import->{paymentIntances}->{bic},
+    #sepa_iban               => $import->{paymentIntances}->{iban},
+    #shipping_costs          => $import->{invoiceShipping},
+    #shipping_costs_net      => $import->{invoiceShippingNet},
+    #shop_c_billing_id       => $import->{billing}->{customerId},
+    #shop_c_billing_number   => $import->{billing}->{number},
+    #shop_c_delivery_id      => $import->{shipping}->{id},
+    #shop_customer_id        => $import->{customerId},
+    #shop_customer_number    => $import->{billing}->{number},
+    #shop_customer_comment   => $import->{customerComment},
 
 =item * Use shipping_costs_parts_id for additional shipping costs
 
@@ -964,6 +1080,12 @@ Right now the returning structure and the common parts of the filter are in two
 
 Many error messages are thrown, but at least the more common cases should be localized.
 
+=item * Multi language support
+
+By guessing the correct german name for the english language some translation for parts can
+also be synced. This should be more clear (language configuration for shops) and the order
+synchronisation should also handle this (longdescription is simply copied from part.notes)
+
 =back
 
 =head1 AUTHOR