Revision 412a13b4
Von Jan Büren vor fast 3 Jahren hinzugefügt
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
Shopware6 Connector. Initiale Version
TODOS s.a. POD