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 Carp;
 use Encode qw(encode);
+use List::Util qw(first);
 use REST::Client;
 use Try::Tiny;
 
 use SL::JSON;
 use SL::Helper::Flash;
 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) ],
 
 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
   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 $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};
   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;
 
   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
 
   $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));
     }
     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
   } 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: $_" };
 
     $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
 }
   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) = @_;
 
 sub sync_all_images {
   my ($self, %params) = @_;
@@ -453,7 +529,7 @@ sub get_categories {
 sub get_one_order  {
   my ($self, $ordnumber) = @_;
 
 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();
   # 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();
   };
   } 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};
   # 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) = @_;
 
 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();
   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) = @_;
 
 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');
   $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
   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};
 
   # 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);
 
 
   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
     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,
                           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 ) {
     $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},
       # 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;
 
 
     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 {
 }
 
 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};
                                                       && 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} } ];
 
 
   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];
     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;
 
   # 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',
 
   # 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;
 
   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
   # 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>
 
 
 =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)>
 
 =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
 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
 
 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>
 
 
 =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.
 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
 
 
 =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.
 
 
 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
 =back
 
 =head1 AUTHOR