11 private array $requestCache = array();
13 private function remember(
string $key, callable $loader): array {
14 if (!array_key_exists($key, $this->requestCache)) {
16 $this->requestCache[$key] = is_array(
$value) ?
$value : array();
18 return $this->requestCache[$key];
21 private function clearRequestCache():
void {
22 $this->requestCache = array();
25 private function db() {
26 return dbx()->get_system_obj(
'dbxDB');
29 private function dd(
string $name):
string {
30 return 'dbxShop|' . $name;
33 private function sqlValue(
$value):
string {
37 return "'" . str_replace(
"'",
"''", (
string)
$value) .
"'";
40 private function sqlLikeValue(
string $value):
string {
41 return $this->sqlValue(
'%' .
$value .
'%');
44 private function syncDdToDb(
string $dd):
bool {
45 $oDD =
dbx()->get_system_obj(
'dbxDD');
46 $oDD->sync_dd_to_db(
'dbxShop',
$dd,
'reset');
47 for ($i = 0; $i < 40; $i++) {
49 $status = (string)(
$state[
'status'] ??
'');
50 if ($status ===
'finished') {
53 if (in_array($status, array(
'error',
'cancelled'),
true)) {
60 private function syncShopSchemaFromDd():
bool {
67 if ((
string)
dbx()->get_config(
'dbxShop',
'schema_sync_version',
'') ===
$version) {
77 'shopAttributeDefinition',
78 'shopProductAttributeValue',
79 'shopProductGroupMap',
82 'shopProductShippingGroupMap',
84 'shopChannelGroupChannel',
85 'shopProductChannelGroupMap',
91 if (!$this->syncDdToDb(
$dd)) {
92 throw new \RuntimeException(
'dbxShop-DD konnte nicht mit der Datenbank synchronisiert werden: ' .
$dd);
96 $cfg =
dbx()->get_config(
'dbxShop',
'', array());
99 dbx()->set_config(
'dbxShop',
$cfg);
111 public function install(
bool $maintenance =
false): void {
112 static $maintenanceDone = false;
113 if (!$maintenance || $maintenanceDone) {
117 $this->syncShopSchemaFromDd();
118 $this->syncChannelDefaults();
119 $this->syncPrimaryProductGroups();
120 $this->syncSingleGroupImages();
121 $this->clearRequestCache();
122 $maintenanceDone =
true;
125 private function syncPrimaryProductGroups(): void {
126 $rows = $this->db()->select($this->dd(
'shopProduct'),
'trash = 0 AND (product_group_id IS NULL OR product_group_id <= 0)',
'id',
'',
'ASC',
'', 0, 0, 0);
127 foreach ((is_array(
$rows) ?
$rows : array()) as $row) {
128 $productId = (int)($row[
'id'] ?? 0);
129 if ($productId <= 0)
continue;
130 $maps = $this->db()->select($this->dd(
'shopProductGroupMap'),
'product_id = ' . $productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 1, 0);
131 $map = is_array($maps) && isset($maps[0]) ? $maps[0] : array();
132 $groupId = (int)($map[
'group_id'] ?? 0);
134 $this->db()->update($this->dd(
'shopProduct'), array(
'product_group_id' => $groupId),
'id = ' . $productId, 0);
139 private function syncSingleGroupImages(): void {
140 $groups = $this->db()->select($this->dd(
'shopProductGroup'),
'trash = 0',
'id',
'',
'ASC',
'', 0, 0, 0);
142 $groupId = (int)($group[
'id'] ?? 0);
143 if ($groupId <= 0)
continue;
144 $rows = $this->db()->select(
145 $this->dd(
'shopProductImage'),
146 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . $groupId,
148 'is_primary DESC, sorter ASC, title ASC',
156 foreach ((is_array(
$rows) ?
$rows : array()) as $row) {
157 $id = (int)($row[
'id'] ?? 0);
158 if ($id <= 0)
continue;
161 $this->db()->update($this->dd(
'shopProductImage'), array(
'is_primary' => 1,
'sorter' => 10),
'id = ' . $id, 0);
164 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1),
'id = ' . $id, 0);
169 private function syncChannelDefaults(): void {
171 array(
'shop',
'Shop',
'Eigener Shop ohne externe API.',
'shop',
'internal', 1, 0,
'',
'',
'',
'', 10),
172 array(
'amazon',
'Amazon',
'Amazon Marketplace Integration fuer Artikel-Export und spaeteren Order-Import.',
'amazon',
'api', 1, 1,
'https://sellingpartnerapi-eu.amazon.com',
'A1PA6795UKMFR9',
'ORDER_CHANGE',
"Listings Items\nOrders\nNotifications", 20),
173 array(
'ebay',
'eBay',
'eBay Marketplace Integration fuer Listings und Bestellrueckmeldungen.',
'ebay',
'api', 1, 1,
'https://api.ebay.com',
'EBAY_DE',
'',
"https://api.ebay.com/oauth/api_scope/sell.inventory\nhttps://api.ebay.com/oauth/api_scope/sell.fulfillment\nhttps://api.ebay.com/oauth/api_scope/commerce.notification.subscription", 30),
174 array(
'kleinanzeigen',
'Kleinanzeigen',
'Kleinanzeigen ist als Channel vorbereitet. Eine allgemein frei nutzbare offizielle Anzeigen-/Order-API ist nicht hinterlegt; nutzen Sie hier nur vertraglich freigegebene Schnittstellen oder manuelle Pflege.',
'kleinanzeigen',
'manual', 1, 0,
'',
'',
'',
'', 40),
175 array(
'mobile',
'mobile.de',
'mobile.de Channel fuer Fahrzeug- oder Angebotsdaten ueber Seller API und Lead API.',
'mobile',
'api', 1, 1,
'https://services.mobile.de/seller-api',
'',
'lead-api',
"seller-api\nbasic-auth\nlead-api", 50),
177 foreach ($channels as $c) {
178 $existing = $this->db()->select1($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($c[0]),
'*', 0);
179 $existing = is_array($existing) ? $existing : array();
181 'channel_key' => $c[0],
182 'title' => trim((
string)($existing[
'title'] ??
'')) !==
'' ? (
string)$existing[
'title'] : $c[1],
183 'description' => trim((
string)($existing[
'description'] ??
'')) !==
'' ? (
string)$existing[
'description'] : $c[2],
184 'platform_type' => trim((
string)($existing[
'platform_type'] ??
'')) !==
'' && (
string)($existing[
'platform_type'] ??
'') !==
'custom' ? (
string)$existing[
'platform_type'] : $c[3],
185 'connection_mode' => trim((
string)($existing[
'connection_mode'] ??
'')) !==
'' && (
string)($existing[
'connection_mode'] ??
'') !==
'manual' ? (
string)$existing[
'connection_mode'] : $c[4],
186 'export_enabled' => array_key_exists(
'export_enabled', $existing) ? (
int)$existing[
'export_enabled'] : $c[5],
187 'order_import_enabled' => array_key_exists(
'order_import_enabled', $existing) ? (
int)$existing[
'order_import_enabled'] : $c[6],
188 'api_base_url' => trim((
string)($existing[
'api_base_url'] ??
'')) !==
'' ? (
string)$existing[
'api_base_url'] : $c[7],
189 'marketplace_id' => trim((
string)($existing[
'marketplace_id'] ??
'')) !==
'' ? (
string)$existing[
'marketplace_id'] : $c[8],
190 'notification_topic' => trim((
string)($existing[
'notification_topic'] ??
'')) !==
'' ? (
string)$existing[
'notification_topic'] : $c[9],
191 'api_scope' => trim((
string)($existing[
'api_scope'] ??
'')) !==
'' ? (
string)$existing[
'api_scope'] : $c[10],
192 'active' => array_key_exists(
'active', $existing) ? (
int)$existing[
'active'] : 1,
193 'sorter' => array_key_exists(
'sorter', $existing) ? (
int)$existing[
'sorter'] : $c[11],
195 $this->db()->save($this->dd(
'shopChannel'), $values,
'channel_key = ' . $this->sqlValue($c[0]), 0);
197 $oldEbayScopes =
'https://api.ebay.com/oauth/api_scope/sell.inventory https://api.ebay.com/oauth/api_scope/sell.fulfillment https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
198 $newEbayScopes =
"https://api.ebay.com/oauth/api_scope/sell.inventory\nhttps://api.ebay.com/oauth/api_scope/sell.fulfillment\nhttps://api.ebay.com/oauth/api_scope/commerce.notification.subscription";
200 $this->dd(
'shopChannel'),
201 array(
'api_scope' => $newEbayScopes),
202 'channel_key = ' . $this->sqlValue(
'ebay') .
' AND api_scope = ' . $this->sqlValue($oldEbayScopes),
207 private function seedDemoProductsWithDbxDb(): void {
208 if ($this->db()->count($this->dd(
'shopProduct'),
'trash = 0') > 0) {
212 $this->updateProductGroup(0, array(
213 'group_key' =>
'software',
214 'title' =>
'Software',
215 'description' =>
'Digitale dbXapp Pakete und Erweiterungen.',
216 'tax_class' =>
'mwst1',
217 'display_variant' =>
'gallery_grid',
218 'card_template' =>
'product-card-default',
219 'detail_template' =>
'product-detail-default',
220 'gallery_template' =>
'image-gallery',
221 'gallery_visible_count' => 3,
222 'gallery_image_size' =>
'original',
223 'gallery_lightbox_width' =>
'100vw',
224 'gallery_overflow' =>
'grid',
225 'gallery_click' =>
'lightbox',
226 'attribute_notes' =>
'Lizenz, Paketumfang, CMS-Funktionen und KI-Funktionen.',
230 $this->updateProductGroup(0, array(
231 'group_key' =>
'service',
232 'title' =>
'Dienstleistungen',
233 'description' =>
'Installation, Wartung und Schulung.',
234 'tax_class' =>
'mwst1',
235 'display_variant' =>
'gallery_slider',
236 'card_template' =>
'product-card-default',
237 'detail_template' =>
'product-detail-default',
238 'gallery_template' =>
'image-gallery',
239 'gallery_visible_count' => 1,
240 'gallery_image_size' =>
'original',
241 'gallery_lightbox_width' =>
'100vw',
242 'gallery_overflow' =>
'slider',
243 'gallery_click' =>
'lightbox',
244 'attribute_notes' =>
'Leistungsumfang, Dauer, Termin und Uebergabe.',
249 $this->updateShippingGroup(0, array(
250 'group_key' =>
'digital-free',
251 'title' =>
'Digital / kein Versand',
252 'description' =>
'Digitale Lieferung ohne Versandkosten.',
253 'shipping_way' =>
'Download / Freischaltung',
254 'delivery_time' =>
'Sofort nach Freischaltung',
255 'shipping_gross' => 0,
256 'free_from_gross' => -1,
260 $this->updateShippingGroup(0, array(
261 'group_key' =>
'service-remote',
262 'title' =>
'Service / Termin',
263 'description' =>
'Terminleistung ohne Paketversand.',
264 'shipping_way' =>
'Termin / Remote / vor Ort nach Absprache',
265 'delivery_time' =>
'Nach Terminvereinbarung',
266 'shipping_gross' => 0,
267 'free_from_gross' => -1,
272 $this->updateChannelGroup(0, array(
273 'group_key' =>
'software-shop',
274 'title' =>
'Software Shop-Artikel',
275 'description' =>
'Softwarepakete fuer Shop und Amazon.',
278 ), array(
'shop',
'amazon'));
279 $this->updateChannelGroup(0, array(
280 'group_key' =>
'service-local',
281 'title' =>
'Service lokal',
282 'description' =>
'Dienstleistungen fuer Shop und Kleinanzeigen.',
285 ), array(
'shop',
'kleinanzeigen'));
288 array(
'DBX-DEMO-START',
'dbxapp-demo-paket',
'dbXapp Demo Paket',
'Software',
'digital',
'software',
'digital-free',
'software-shop',
'Kleine Website mit CMS, Medienverwaltung und direkter Frontend-Bearbeitung.',
'Testartikel fuer den Einstieg: Content-Seiten, Hero, Gallery und Medienbrowser sind vorbereitet.', 99.00, 0, 10,
'Demo',
'bi-window-sidebar', array(
'shop',
'amazon')),
289 array(
'DBX-INSTALLATION',
'dbxapp-installation',
'Installation',
'Dienstleistung',
'service',
'service',
'service-remote',
'service-local',
'Einrichtung von dbXapp auf Hosting oder eigenem Server.',
'Installation, Grundkonfiguration, erste Systempruefung und Uebergabe der lauffaehigen Umgebung.', 249.00, 0, 40,
'Service',
'bi-tools', array(
'shop',
'kleinanzeigen')),
291 foreach ($products as $p) {
293 $this->dd(
'shopProduct'),
299 'product_group_id' => $this->groupIdByKey($p[5]),
300 'product_type' => $p[4],
302 'description' => $p[9],
303 'price_gross' => $p[10],
305 'tax_mode' =>
'group',
307 'shipping_mode' =>
'group',
308 'shipping_gross' => -1,
313 'image_icon' => $p[14],
314 'logo_variant' =>
'',
316 'sku = ' . $this->sqlValue($p[0]),
319 $product = $this->productBySku($p[0],
false);
320 $productId = (int)($product[
'id'] ?? 0);
321 if ($productId <= 0)
continue;
322 $groupId = $this->groupIdByKey($p[5]);
324 $this->db()->save($this->dd(
'shopProductGroupMap'), array(
'product_id' => $productId,
'group_id' => $groupId,
'is_primary' => 1),
'product_id = ' . $productId .
' AND group_id = ' . $groupId, 0);
326 $shippingGroupId = $this->shippingGroupIdByKey($p[6]);
327 if ($shippingGroupId > 0) {
328 $this->db()->save($this->dd(
'shopProductShippingGroupMap'), array(
'product_id' => $productId,
'shipping_group_id' => $shippingGroupId,
'is_primary' => 1),
'product_id = ' . $productId .
' AND shipping_group_id = ' . $shippingGroupId, 0);
330 $channelGroupId = $this->channelGroupIdByKey($p[7]);
331 if ($channelGroupId > 0) {
332 $this->db()->save($this->dd(
'shopProductChannelGroupMap'), array(
'product_id' => $productId,
'channel_group_id' => $channelGroupId,
'is_primary' => 1),
'product_id = ' . $productId .
' AND channel_group_id = ' . $channelGroupId, 0);
334 foreach ($p[15] as $channelKey) {
335 $this->db()->save($this->dd(
'shopProductChannel'), array(
'product_id' => $productId,
'channel_key' => $channelKey,
'active' => 1,
'channel_sku' => $p[0],
'price_gross' => -1,
'shipping_gross' => -1),
'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey), 0);
339 $softwareGroupId = $this->groupIdByKey(
'software');
340 if ($softwareGroupId > 0) {
341 $this->saveImage(0, $softwareGroupId,
'files/shop/img/software-dashboard.svg',
'Software Dashboard',
'dbXapp Software Dashboard', 1, 10);
343 $serviceGroupId = $this->groupIdByKey(
'service');
344 if ($serviceGroupId > 0) {
345 $this->saveImage(0, $serviceGroupId,
'files/shop/img/service-support.svg',
'Service und Schulung',
'dbXapp Installation, Wartung und Schulung', 1, 10);
351 $this->seedDemoProductsWithDbxDb();
352 $this->clearRequestCache();
359 return $this->db()->count($this->dd(
'shopProduct'),
'trash = 0') === 0
360 || $this->db()->count($this->dd(
'shopShippingGroup'),
'trash = 0') === 0
361 || $this->db()->count($this->dd(
'shopChannelGroup'),
'trash = 0') === 0
362 || $this->db()->count($this->dd(
'shopProductImage'),
'trash = 0') === 0;
365 private function groupIdByKey(
string $key): int {
366 $row = $this->db()->select1($this->dd(
'shopProductGroup'),
'group_key = ' . $this->sqlValue($key),
'id', 0);
367 return (
int)($row[
'id'] ?? 0);
370 private function shippingGroupIdByKey(
string $key): int {
371 $row = $this->db()->select1($this->dd(
'shopShippingGroup'),
'group_key = ' . $this->sqlValue($key),
'id', 0);
372 return (
int)($row[
'id'] ?? 0);
375 private function channelGroupIdByKey(
string $key): int {
376 $row = $this->db()->select1($this->dd(
'shopChannelGroup'),
'group_key = ' . $this->sqlValue($key),
'id', 0);
377 return (
int)($row[
'id'] ?? 0);
380 private function valueNum(
string $value): ?float {
381 $clean = str_replace(
',',
'.', trim(
$value));
382 return is_numeric($clean) ? (float)$clean : null;
387 $where =
'trash = 0 AND group_id = ' . (int)$groupId;
389 $where .=
' AND active = 1';
391 $rows = $this->db()->select($this->dd(
'shopAttributeDefinition'), $where,
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
401 if ($id <= 0 || isset($seen[$id]))
continue;
411 $values = $this->db()->select($this->dd(
'shopProductAttributeValue'),
'product_id = ' . (
int)$productId .
' AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
412 $valueByAttribute = array();
413 foreach ((is_array($values) ? $values : array()) as
$value) {
414 $valueByAttribute[(int)(
$value[
'attribute_id'] ?? 0)] =
$value;
418 foreach ($this->attributeDefinitionsForProduct($productId,
true) as
$definition) {
420 if ($attributeId <= 0) {
423 $value = $valueByAttribute[$attributeId] ?? array();
430 foreach (
$rows as &$row) {
431 $value = trim((
string)($row[
'value_text'] ??
''));
432 $unit = trim((
string)($row[
'unit_override'] ??
'')) ?: trim((
string)($row[
'unit'] ??
''));
433 $row[
'display_value'] =
$value !==
'' && $unit !==
'' ?
$value .
' ' . $unit :
$value;
441 return $this->remember(
'attribute_definitions',
function(): array {
443 $groupById = array();
445 $groupById[(int)($group[
'id'] ?? 0)] = $group;
447 $defs = $this->db()->select($this->dd(
'shopAttributeDefinition'),
'trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
448 $defs = is_array($defs) ? $defs : array();
449 foreach ($defs as &$def) {
450 $group = $groupById[(int)($def[
'group_id'] ?? 0)] ?? array();
451 $def[
'group_title'] = (string)($group[
'title'] ??
'');
452 $def[
'group_key'] = (string)($group[
'group_key'] ??
'');
453 $def[
'_group_sorter'] = (int)($group[
'sorter'] ?? 9999);
456 usort($defs, fn($a, $b) => ((
int)($a[
'_group_sorter'] ?? 9999) <=> (
int)($b[
'_group_sorter'] ?? 9999))
457 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
458 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
459 foreach ($defs as &$def) {
460 unset($def[
'_group_sorter']);
469 return $this->remember(
'attribute_filter_definitions',
function(): array {
470 $defs = array_values(array_filter(
472 fn($def) => (
int)($def[
'active'] ?? 0) === 1 && (
int)($def[
'filterable'] ?? 0) === 1
474 $products = $this->db()->select($this->dd(
'shopProduct'),
'trash = 0 AND active = 1',
'id',
'',
'ASC',
'', 0, 0, 0);
475 $activeProducts = array();
476 foreach ((is_array($products) ? $products : array()) as $product) {
477 $activeProducts[(int)($product[
'id'] ?? 0)] =
true;
479 $definitionIds = array_values(array_filter(array_map(
480 static fn($def) => (
int)($def[
'id'] ?? 0),
483 $valuesByDefinition = array();
484 if ($definitionIds && $activeProducts) {
485 $rows = $this->db()->select(
486 $this->dd(
'shopProductAttributeValue'),
487 'attribute_id IN (' . implode(
',', $definitionIds) .
')'
488 .
' AND product_id IN (' . implode(
',', array_keys($activeProducts)) .
')'
489 .
' AND trash = 0 AND active = 1',
490 'attribute_id,product_id,value_text',
498 foreach ((is_array(
$rows) ?
$rows : array()) as $row) {
499 $attributeId = (int)($row[
'attribute_id'] ?? 0);
500 $value = trim((
string)($row[
'value_text'] ??
''));
501 if ($attributeId > 0 &&
$value !==
'') {
506 foreach ($defs as &$def) {
507 $values = array_values($valuesByDefinition[(
int)($def[
'id'] ?? 0)] ?? array());
508 natcasesort($values);
509 $values = array_values($values);
510 if ($values === array() && trim((
string)($def[
'options'] ??
'')) !==
'') {
511 $values = preg_split(
'~[|;\r\n]+~', (
string)$def[
'options']) ?: array();
512 $values = array_values(array_filter(array_map(
'trim', $values), fn($v) => $v !==
''));
514 $def[
'values'] = $values;
523 $id = (int)($data[
'id'] ?? 0);
524 $groupId = (int)($data[
'group_id'] ?? 0);
525 $key = strtolower(trim(preg_replace(
'~[^a-z0-9_\\-]+~i',
'_', (
string)($data[
'attr_key'] ??
''))));
526 $title = trim((
string)($data[
'title'] ??
''));
527 if ($groupId <= 0 || $key ===
'' || $title ===
'') {
530 $type = (string)($data[
'input_type'] ??
'text');
531 if (!in_array($type, array(
'text',
'select',
'number'),
true)) {
535 'group_id' => $groupId,
538 'input_type' => $type,
539 'unit' => trim((
string)($data[
'unit'] ??
'')),
540 'options' => trim((
string)($data[
'options'] ??
'')),
541 'required' => !empty($data[
'required']) ? 1 : 0,
542 'filterable' => !empty($data[
'filterable']) ? 1 : 0,
543 'comparable' => !empty($data[
'comparable']) ? 1 : 0,
544 'active' => !empty($data[
'active']) ? 1 : 0,
545 'sorter' => (
int)($data[
'sorter'] ?? 100),
548 $this->db()->update($this->dd(
'shopAttributeDefinition'), $values,
'id = ' . (
int)$id, 0);
549 $this->clearRequestCache();
553 $this->dd(
'shopAttributeDefinition'),
555 'group_id = ' . (
int)$groupId .
' AND attr_key = ' . $this->sqlValue($key),
558 $this->clearRequestCache();
563 if ($productId <= 0 || $attributeId <= 0) {
567 $num = $this->valueNum(
$value);
569 $this->dd(
'shopProductAttributeValue'),
571 'product_id' => $productId,
572 'attribute_id' => $attributeId,
575 'active' =>
$value !==
'' ? 1 : 0,
577 'product_id = ' . (
int)$productId .
' AND attribute_id = ' . (
int)$attributeId,
580 $this->clearRequestCache();
590 private function rowsByIntKey(array
$rows,
string $field): array {
592 foreach (
$rows as $row) {
593 $key = (int)($row[
$field] ?? 0);
595 $indexed[$key][] = $row;
602 private function rowsById(array
$rows): array {
604 foreach (
$rows as $row) {
605 $id = (int)($row[
'id'] ?? 0);
607 $indexed[$id] = $row;
618 private function mappedGroupRows(array $maps, array $groupById,
string $groupIdField): array {
620 foreach ($maps as $map) {
621 $groupId = (int)($map[$groupIdField] ?? 0);
622 if (!isset($groupById[$groupId]))
continue;
623 $row = $groupById[$groupId];
624 $row[
'_is_primary'] = (int)($map[
'is_primary'] ?? 0);
627 usort(
$rows, fn($a, $b) => ((
int)($b[
'_is_primary'] ?? 0) <=> (
int)($a[
'_is_primary'] ?? 0))
628 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
629 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
630 foreach (
$rows as &$row) {
631 unset($row[
'_is_primary']);
643 private function decorateProducts(array
$rows): array {
644 if (
$rows === array()) return array();
646 $productIds = array_values(array_unique(array_filter(array_map(
647 static fn($row) => (
int)($row[
'id'] ?? 0),
650 if ($productIds === array())
return array();
651 $productIdSql = implode(
',', array_map(
'intval', $productIds));
654 $productGroups = $this->db()->select(
655 $this->dd(
'shopProductGroup'),
665 $productGroupById = $this->rowsById(is_array($productGroups) ? $productGroups : array());
666 $productGroupMaps = $this->db()->select(
667 $this->dd(
'shopProductGroupMap'),
668 'product_id IN (' . $productIdSql .
')',
677 $productGroupMapsByProduct = $this->rowsByIntKey(
678 is_array($productGroupMaps) ? $productGroupMaps : array(),
682 $shippingGroups = $this->db()->select(
683 $this->dd(
'shopShippingGroup'),
693 $shippingGroupById = $this->rowsById(is_array($shippingGroups) ? $shippingGroups : array());
694 $shippingMaps = $this->db()->select(
695 $this->dd(
'shopProductShippingGroupMap'),
696 'product_id IN (' . $productIdSql .
')',
705 $shippingMapsByProduct = $this->rowsByIntKey(
706 is_array($shippingMaps) ? $shippingMaps : array(),
710 $channelGroups = $this->db()->select(
711 $this->dd(
'shopChannelGroup'),
721 $channelGroupById = $this->rowsById(is_array($channelGroups) ? $channelGroups : array());
722 $channelGroupMaps = $this->db()->select(
723 $this->dd(
'shopProductChannelGroupMap'),
724 'product_id IN (' . $productIdSql .
')',
733 $channelGroupMaps = is_array($channelGroupMaps) ? $channelGroupMaps : array();
734 $channelGroupMapsByProduct = $this->rowsByIntKey($channelGroupMaps,
'product_id');
736 $channelIndex = array();
737 foreach ($this->channels() as $channel) {
738 $key = (string)($channel[
'channel_key'] ??
'');
739 if ($key !==
'') $channelIndex[$key] = $channel;
741 $directChannels = $this->db()->select(
742 $this->dd(
'shopProductChannel'),
743 'product_id IN (' . $productIdSql .
')',
752 $directChannelsByProduct = $this->rowsByIntKey(
753 is_array($directChannels) ? $directChannels : array(),
756 $mappedChannelGroupIds = array_values(array_unique(array_filter(array_map(
757 static fn($map) => (
int)($map[
'channel_group_id'] ?? 0),
760 $channelGroupChannelsByGroup = array();
761 if ($mappedChannelGroupIds !== array()) {
762 $channelGroupChannels = $this->db()->select(
763 $this->dd(
'shopChannelGroupChannel'),
764 'channel_group_id IN (' . implode(
',', array_map(
'intval', $mappedChannelGroupIds)) .
') AND active = 1',
773 $channelGroupChannelsByGroup = $this->rowsByIntKey(
774 is_array($channelGroupChannels) ? $channelGroupChannels : array(),
779 $definitions = $this->db()->select(
780 $this->dd(
'shopAttributeDefinition'),
781 'active = 1 AND trash = 0',
783 'sorter ASC, title ASC',
790 $definitionsByGroup = $this->rowsByIntKey(
791 is_array($definitions) ? $definitions : array(),
794 $attributeValues = $this->db()->select(
795 $this->dd(
'shopProductAttributeValue'),
796 'product_id IN (' . $productIdSql .
') AND trash = 0',
805 $attributeValuesByProduct = array();
806 foreach ((is_array($attributeValues) ? $attributeValues : array()) as
$value) {
807 $productId = (int)(
$value[
'product_id'] ?? 0);
808 $attributeId = (int)(
$value[
'attribute_id'] ?? 0);
809 if ($productId > 0 && $attributeId > 0) {
810 $attributeValuesByProduct[$productId][$attributeId] =
$value;
815 $imageGroupIds = array();
816 foreach (
$rows as $row) {
817 $productId = (int)($row[
'id'] ?? 0);
818 $directGroupId = (int)($row[
'product_group_id'] ?? 0);
819 if ($directGroupId > 0 && isset($productGroupById[$directGroupId])) {
820 $imageGroupIds[$directGroupId] = $directGroupId;
823 foreach ((array)($productGroupMapsByProduct[$productId] ?? array()) as $map) {
824 $groupId = (int)($map[
'group_id'] ?? 0);
825 if (isset($productGroupById[$groupId])) $imageGroupIds[$groupId] = $groupId;
828 $imageWhere =
'trash = 0 AND active = 1 AND (product_id IN (' . $productIdSql .
')';
829 if ($imageGroupIds !== array()) {
830 $imageWhere .=
' OR group_id IN (' . implode(
',', array_map(
'intval', $imageGroupIds)) .
')';
833 $imageRows = $this->db()->select(
834 $this->dd(
'shopProductImage'),
837 'is_primary DESC, sorter ASC, title ASC',
844 $imageRows = is_array($imageRows) ? $imageRows : array();
845 $imagesByProduct = $this->rowsByIntKey($imageRows,
'product_id');
847 foreach (
$rows as &$row) {
848 $productId = (int)($row[
'id'] ?? 0);
850 $directGroupId = (int)($row[
'product_group_id'] ?? 0);
851 if ($directGroupId > 0 && isset($productGroupById[$directGroupId])) {
852 $row[
'groups'] = array($productGroupById[$directGroupId]);
854 $row[
'groups'] = $this->mappedGroupRows(
855 (array)($productGroupMapsByProduct[$productId] ?? array()),
860 $row[
'shipping_groups'] = $this->mappedGroupRows(
861 (array)($shippingMapsByProduct[$productId] ?? array()),
865 $row[
'channel_groups'] = $this->mappedGroupRows(
866 (array)($channelGroupMapsByProduct[$productId] ?? array()),
873 foreach ((array)($directChannelsByProduct[$productId] ?? array()) as $direct) {
874 $key = (string)($direct[
'channel_key'] ??
'');
875 $base = $channelIndex[$key] ?? array(
'title' => $key,
'sorter' => 9999);
877 'channel_key' => $key,
878 'title' => (
string)(
$base[
'title'] ?? $key),
879 'active' => (
int)($direct[
'active'] ?? 0),
880 'channel_sku' => (
string)($direct[
'channel_sku'] ??
''),
881 'price_gross' => (
float)($direct[
'price_gross'] ?? -1),
882 'shipping_gross' => (
float)($direct[
'shipping_gross'] ?? -1),
883 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
886 usort($channels, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
887 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
888 foreach ((array)($channelGroupMapsByProduct[$productId] ?? array()) as $groupMap) {
889 $groupId = (int)($groupMap[
'channel_group_id'] ?? 0);
890 foreach ((array)($channelGroupChannelsByGroup[$groupId] ?? array()) as $inherited) {
891 $key = (string)($inherited[
'channel_key'] ??
'');
892 $base = $channelIndex[$key] ?? array(
'title' => $key,
'sorter' => 9999);
894 'channel_key' => $key,
895 'title' => (
string)(
$base[
'title'] ?? $key),
899 'shipping_gross' => -1,
900 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
904 $row[
'channels'] = array();
905 $seenChannels = array();
906 foreach ($channels as $channel) {
907 $key = (string)($channel[
'channel_key'] ??
'');
908 if ($key ===
'' || isset($seenChannels[$key]))
continue;
909 $seenChannels[$key] =
true;
910 if ((
int)($channel[
'active'] ?? 0) === 1) {
911 unset($channel[
'_sorter']);
912 $row[
'channels'][] = $channel;
917 foreach ($row[
'groups'] as $group) {
918 $groupId = (int)($group[
'id'] ?? 0);
919 if ($groupId > 0) $groupIds[$groupId] =
true;
921 $images = (array)($imagesByProduct[$productId] ?? array());
922 foreach ($imageRows as $image) {
923 if (isset($groupIds[(
int)($image[
'group_id'] ?? 0)])) $images[] = $image;
925 $row[
'images'] = array();
926 $seenImages = array();
927 foreach ($images as $image) {
928 $path = (string)($image[
'image_path'] ??
'');
929 $mediaId = (int)($image[
'media_id'] ?? 0);
931 if ((
$mediaId <= 0 && $path ===
'') || isset($seenImages[$key]))
continue;
932 $seenImages[$key] =
true;
933 $row[
'images'][] = $image;
936 $row[
'attributes'] = array();
937 $seenDefinitions = array();
938 foreach ($row[
'groups'] as $group) {
939 $groupId = (int)($group[
'id'] ?? 0);
940 foreach ((array)($definitionsByGroup[$groupId] ?? array()) as
$definition) {
942 if ($attributeId <= 0 || isset($seenDefinitions[$attributeId]))
continue;
943 $seenDefinitions[$attributeId] =
true;
944 $value = $attributeValuesByProduct[$productId][$attributeId] ?? array();
952 $definition[
'display_value'] = $text !==
'' && $unit !==
''
953 ? $text .
' ' . $unit
959 $primary = $row[
'groups'][0] ?? array();
960 $shipping = $row[
'shipping_groups'][0] ?? array();
961 $row[
'effective_tax_rate'] = $this->taxRateForClass(
962 (
string)($primary[
'tax_class'] ??
''),
963 (
float)($primary[
'default_tax_rate'] ?? 19)
965 $row[
'effective_shipping_gross'] = (string)($row[
'shipping_mode'] ??
'group') ===
'individual'
966 && (float)($row[
'shipping_gross'] ?? -1) >= 0
967 ? (float)$row[
'shipping_gross']
968 : (float)($shipping[
'shipping_gross'] ?? $primary[
'default_shipping_gross'] ?? 0);
969 $row[
'effective_shipping_way'] = (string)($shipping[
'shipping_way'] ??
'');
970 $row[
'effective_delivery_time'] = trim((
string)($row[
'delivery_time'] ??
'')) !==
''
971 ? trim((
string)$row[
'delivery_time'])
972 : trim((string)($shipping[
'delivery_time'] ??
''));
978 private function decorateProduct(array $row): array {
979 $row[
'groups'] = $this->groupsForProduct((int)$row[
'id']);
980 $row[
'shipping_groups'] = $this->shippingGroupsForProduct((
int)$row[
'id']);
981 $row[
'channel_groups'] = $this->channelGroupsForProduct((
int)$row[
'id']);
982 $row[
'channels'] = $this->channelsForProduct((
int)$row[
'id']);
983 $row[
'images'] = $this->imagesForProduct((
int)$row[
'id'], $row[
'groups']);
984 $row[
'attributes'] = $this->attributesForProduct((
int)$row[
'id']);
985 $primary = $row[
'groups'][0] ?? array();
986 $shipping = $row[
'shipping_groups'][0] ?? array();
987 $row[
'effective_tax_rate'] = $this->taxRateForClass(
988 (
string)($primary[
'tax_class'] ??
''),
989 (
float)($primary[
'default_tax_rate'] ?? 19)
991 $row[
'effective_shipping_gross'] = (string)($row[
'shipping_mode'] ??
'group') ===
'individual' && (float)($row[
'shipping_gross'] ?? -1) >= 0
992 ? (float)$row[
'shipping_gross']
993 : (float)($shipping[
'shipping_gross'] ?? $primary[
'default_shipping_gross'] ?? 0);
994 $row[
'effective_shipping_way'] = (string)($shipping[
'shipping_way'] ??
'');
995 $row[
'effective_delivery_time'] = trim((
string)($row[
'delivery_time'] ??
'')) !==
''
996 ? trim((
string)$row[
'delivery_time'])
997 : trim((string)($shipping[
'delivery_time'] ??
''));
1001 private function taxRatesConfig(): array {
1003 'mwst1' => array(
'title' =>
'MwSt. normal',
'rate' =>
'19'),
1004 'mwst2' => array(
'title' =>
'MwSt. ermaessigt',
'rate' =>
'7'),
1005 'mwst3' => array(
'title' =>
'MwSt. vorbereitet',
'rate' =>
'22'),
1007 if (!function_exists(
'dbx')) {
1014 private function taxRateForClass(
string $taxClass,
float $fallback): float {
1015 $taxClass = trim($taxClass);
1016 $rates = $this->taxRatesConfig();
1017 if ($taxClass !==
'' && isset($rates[$taxClass]) && is_array($rates[$taxClass])) {
1018 return (
float)($rates[$taxClass][
'rate'] ??
$fallback);
1020 $defaultClass = function_exists(
'dbx') ? (string)
dbx()->get_config(
'dbxShop',
'default_tax_class',
'mwst1') :
'mwst1';
1021 if (isset($rates[$defaultClass]) && is_array($rates[$defaultClass])) {
1022 return (
float)($rates[$defaultClass][
'rate'] ??
$fallback);
1027 public function products(
bool $activeOnly =
true): array {
1029 $where = $activeOnly ?
'active = 1 AND trash = 0' :
'trash = 0';
1030 $rows = $this->db()->select($this->dd(
'shopProduct'), $where,
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1032 return $this->decorateProducts(
$rows);
1044 $channelKey = trim($channelKey);
1045 $rows = $this->db()->select(
1046 $this->dd(
'shopProduct'),
1047 'active = 1 AND trash = 0',
1049 'sorter ASC, title ASC',
1057 if (
$rows === array())
return array();
1059 $ids = array_values(array_filter(array_map(
1060 static fn($row) => (
int)($row[
'id'] ?? 0),
1063 if ($ids === array())
return array();
1064 $idSql = implode(
',', array_map(
'intval', $ids));
1067 $groupById = array();
1069 $groupById[(int)($group[
'id'] ?? 0)] = $group;
1071 $groupMaps = $this->db()->select(
1072 $this->dd(
'shopProductGroupMap'),
1073 'product_id IN (' . $idSql .
')',
1082 $groupMapsByProduct = array();
1083 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $map) {
1084 $productId = (int)($map[
'product_id'] ?? 0);
1085 $groupId = (int)($map[
'group_id'] ?? 0);
1086 if ($productId > 0 && $groupId > 0) {
1087 $groupMapsByProduct[$productId][] = $map;
1091 $defs = $this->db()->select(
1092 $this->dd(
'shopAttributeDefinition'),
1093 'active = 1 AND trash = 0',
1095 'sorter ASC, title ASC',
1103 foreach ((is_array($defs) ? $defs : array()) as $def) {
1104 $defById[(int)($def[
'id'] ?? 0)] = $def;
1106 $attributeValues = $this->db()->select(
1107 $this->dd(
'shopProductAttributeValue'),
1108 'product_id IN (' . $idSql .
') AND trash = 0',
1117 $attributeValuesByProduct = array();
1118 foreach ((is_array($attributeValues) ? $attributeValues : array()) as
$value) {
1119 $productId = (int)(
$value[
'product_id'] ?? 0);
1120 $attributeId = (int)(
$value[
'attribute_id'] ?? 0);
1121 if ($productId > 0 && isset($defById[$attributeId])) {
1122 $attributeValuesByProduct[$productId][$attributeId] =
$value;
1128 $directRows = $this->db()->select(
1129 $this->dd(
'shopProductChannel'),
1130 'product_id IN (' . $idSql .
') AND channel_key = ' . $this->sqlValue($channelKey),
1139 $directByProduct = array();
1140 foreach ((is_array($directRows) ? $directRows : array()) as $direct) {
1141 $directByProduct[(int)($direct[
'product_id'] ?? 0)] = $direct;
1143 $channelGroupMaps = $this->db()->select(
1144 $this->dd(
'shopProductChannelGroupMap'),
1145 'product_id IN (' . $idSql .
')',
1154 $channelGroupIds = array();
1155 foreach ((is_array($channelGroupMaps) ? $channelGroupMaps : array()) as $map) {
1156 $groupId = (int)($map[
'channel_group_id'] ?? 0);
1157 if ($groupId > 0) $channelGroupIds[$groupId] = $groupId;
1159 $activeChannelGroups = array();
1160 if ($channelGroupIds) {
1161 $groupChannels = $this->db()->select(
1162 $this->dd(
'shopChannelGroupChannel'),
1163 'channel_group_id IN (' . implode(
',', $channelGroupIds) .
')'
1164 .
' AND channel_key = ' . $this->sqlValue($channelKey)
1165 .
' AND active = 1',
1174 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $groupChannel) {
1175 $activeChannelGroups[(int)($groupChannel[
'channel_group_id'] ?? 0)] =
true;
1178 $inheritedByProduct = array();
1179 foreach ((is_array($channelGroupMaps) ? $channelGroupMaps : array()) as $map) {
1180 $productId = (int)($map[
'product_id'] ?? 0);
1181 $groupId = (int)($map[
'channel_group_id'] ?? 0);
1182 if ($productId > 0 && isset($activeChannelGroups[$groupId])) {
1183 $inheritedByProduct[$productId] =
true;
1187 foreach (
$rows as &$row) {
1188 $productId = (int)($row[
'id'] ?? 0);
1189 $rowGroups = array();
1190 $groupIds = array();
1191 $directGroupId = (int)($row[
'product_group_id'] ?? 0);
1192 if ($directGroupId > 0 && isset($groupById[$directGroupId])) {
1193 $rowGroups[] = $groupById[$directGroupId];
1194 $groupIds[$directGroupId] =
true;
1196 foreach ((array)($groupMapsByProduct[$productId] ?? array()) as $map) {
1197 $groupId = (int)($map[
'group_id'] ?? 0);
1198 if (isset($groupById[$groupId])) {
1199 $rowGroups[] = $groupById[$groupId];
1200 $groupIds[$groupId] =
true;
1204 $row[
'groups'] = $rowGroups;
1206 $row[
'attributes'] = array();
1207 foreach ((array)($attributeValuesByProduct[$productId] ?? array()) as $attributeId =>
$value) {
1208 $def = $defById[(int)$attributeId] ?? array();
1209 if (!$def || !isset($groupIds[(
int)($def[
'group_id'] ?? 0)]))
continue;
1210 $def[
'value_text'] =
$value[
'value_text'] ??
'';
1211 $def[
'value_num'] =
$value[
'value_num'] ??
'';
1212 $def[
'unit_override'] =
$value[
'unit_override'] ??
'';
1213 $def[
'value_active'] =
$value[
'active'] ?? 0;
1214 $text = trim((
string)$def[
'value_text']);
1215 $unit = trim((
string)$def[
'unit_override']) ?: trim((
string)($def[
'unit'] ??
''));
1216 $def[
'display_value'] = $text !==
'' && $unit !==
'' ? $text .
' ' . $unit : $text;
1217 $row[
'attributes'][] = $def;
1220 $direct = $directByProduct[$productId] ??
null;
1221 $channelActive = is_array($direct)
1222 ? (int)($direct[
'active'] ?? 0) === 1
1223 : isset($inheritedByProduct[$productId]);
1224 $row[
'channels'] = $channelActive
1225 ? array(array(
'channel_key' => $channelKey,
'active' => 1))
1235 $ids = $this->normalizeProductIds($ids);
1236 if ($ids === array())
return array();
1237 $rows = $this->db()->select(
1238 $this->dd(
'shopProduct'),
1239 'id IN (' . implode(
',', $ids) .
') AND active = 1 AND trash = 0',
1249 foreach ($this->decorateProducts(is_array(
$rows) ?
$rows : array()) as $row) {
1250 $byId[(int)($row[
'id'] ?? 0)] = $row;
1253 foreach ($ids as $id) {
1259 public function productBySku(
string $sku,
bool $activeOnly =
true): ?array {
1261 $where =
'sku = ' . $this->sqlValue($sku) .
' AND trash = 0';
1263 $where .=
' AND active = 1';
1265 $row = $this->db()->select1($this->dd(
'shopProduct'), $where,
'*', 0);
1266 return is_array($row) ? $this->decorateProduct($row) : null;
1271 $row = $this->db()->select1($this->dd(
'shopProduct'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
1272 return is_array($row) ? $this->decorateProduct($row) : null;
1280 $row = $this->db()->select1($this->dd(
'shopProductGroup'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
1281 return is_array($row) ? $row :
null;
1284 private function stockEnabled(): bool {
1285 $cfg = function_exists(
'dbx') ?
dbx()->get_config(
'dbxShop',
'', array()) : array();
1286 return is_array(
$cfg) && !empty(
$cfg[
'stock_enabled']);
1289 private function requiresStock(array $product): bool {
1290 return $this->stockEnabled() && (string)($product[
'product_type'] ??
'') ===
'physical';
1293 private function isPhysicalProduct(array $product): bool {
1294 return (string)($product[
'product_type'] ??
'') ===
'physical';
1300 foreach (
$items as $sku => $qty) {
1301 $qty = max(1, (
int)$qty);
1303 if (!$product || !$this->requiresStock($product)) {
1306 $stock = (int)($product[
'stock'] ?? 0);
1307 if ($stock < $qty) {
1309 'sku' => (
string)($product[
'sku'] ?? $sku),
1310 'title' => (
string)($product[
'title'] ?? $sku),
1311 'requested' => $qty,
1319 private function hasReservableStockSnapshots(array $snapshots): bool {
1320 if (!$this->stockEnabled()) {
1323 foreach ($snapshots as $item) {
1324 if ((
int)($item[
'product_id'] ?? 0) > 0
1325 && (
string)($item[
'product_type'] ??
'') ===
'physical')
return true;
1330 private function reserveStockForSnapshots(array $snapshots): int {
1331 if (!$this->stockEnabled()) {
1336 $server =
$db->get_dd_server($this->dd(
'shopProduct'));
1337 $table =
$db->get_dd_table($this->dd(
'shopProduct'));
1338 $uid = function_exists(
'dbx') ? (int)
dbx()->user() : 0;
1339 $now = date(
'Y-m-d H:i:s');
1340 foreach ($snapshots as $item) {
1341 $productId = (int)($item[
'product_id'] ?? 0);
1342 if ($productId <= 0 || (
string)($item[
'product_type'] ??
'') !==
'physical') {
1345 $qty = max(1, (
int)($item[
'qty'] ?? 1));
1346 $sql =
'UPDATE ' .
$table
1347 .
' SET stock = stock - ' . $qty
1348 .
', update_date = ' . $this->sqlValue(
$now)
1349 .
', update_uid = ' . $uid
1350 .
' WHERE id = ' . $productId
1351 .
' AND trash = 0 AND stock >= ' . $qty;
1352 if ((
int)
$db->update_query(
$server, $sql) !== 1) {
1353 throw new \RuntimeException(
1354 'Nicht genuegend Lagerbestand fuer ' . (
string)($item[
'title'] ?? $item[
'sku'] ??
'Artikel') .
'.'
1362 private function releaseStockForOrder(array
$order,
string $reason): bool {
1363 $orderId = (int)(
$order[
'id'] ?? 0);
1364 if ($orderId <= 0 || (
int)(
$order[
'stock_reserved'] ?? 0) !== 1 || (
int)(
$order[
'stock_released'] ?? 0) === 1) {
1369 if (
$db->begin($this->dd(
'shopOrder')) !== 1) {
1374 $now = date(
'Y-m-d H:i:s');
1375 if (
$db->update($this->dd(
'shopOrder'), array(
1376 'stock_released' => 1,
1377 'stock_released_date' =>
$now,
1378 'update_date' =>
$now,
1379 ),
'id = ' . $orderId .
' AND trash = 0 AND stock_reserved = 1 AND stock_released = 0', 0) !== 1
1380 || (
int)
$db->_update_count !== 1) {
1381 $db->rollback($this->dd(
'shopOrder'));
1386 $server =
$db->get_dd_server($this->dd(
'shopProduct'));
1387 $table =
$db->get_dd_table($this->dd(
'shopProduct'));
1388 $uid = function_exists(
'dbx') ? (int)
dbx()->user() : 0;
1389 foreach ((array)(
$order[
'items'] ?? array()) as $item) {
1390 $productId = (int)($item[
'product_id'] ?? 0);
1391 if ($productId <= 0)
continue;
1392 $product =
$db->select1(
1393 $this->dd(
'shopProduct'),
1394 'id = ' . $productId .
' AND trash = 0',
1398 if (!is_array($product) || !$this->isPhysicalProduct($product))
continue;
1400 $qty = max(1, (
int)($item[
'qty'] ?? 1));
1401 $sql =
'UPDATE ' .
$table
1402 .
' SET stock = stock + ' . $qty
1403 .
', update_date = ' . $this->sqlValue(
$now)
1404 .
', update_uid = ' . $uid
1405 .
' WHERE id = ' . $productId .
' AND trash = 0';
1406 if ((
int)
$db->update_query(
$server, $sql) !== 1) {
1407 throw new \RuntimeException(
'stock_release_update_failed');
1413 || !$this->addOrderHistory($orderId,
'stock_release',
'', (
string)$released, $reason)
1414 ||
$db->commit($this->dd(
'shopOrder')) !== 1) {
1415 throw new \RuntimeException(
'stock_release_commit_failed');
1418 }
catch (\Throwable $e) {
1419 $db->rollback($this->dd(
'shopOrder'));
1420 dbx()->debug(
'#Shop stock release rollback order=(' . $orderId .
') error=(' . $e->getMessage() .
')');
1425 private function normalizeProductIds(array $ids): array {
1427 foreach ($ids as $id) {
1433 return array_values($clean);
1436 private function channelExists(
string $channelKey): bool {
1437 $channelKey = trim($channelKey);
1438 if ($channelKey ===
'') {
1441 return $this->db()->count($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($channelKey) .
' AND trash = 0') > 0;
1444 private function productGroupExists(
int $groupId): bool {
1445 if ($groupId <= 0) {
1448 return $this->db()->count($this->dd(
'shopProductGroup'),
'id = ' . (
int)$groupId .
' AND trash = 0') > 0;
1453 $ids = $this->normalizeProductIds($ids);
1454 if ($ids === array()) {
1458 $where =
'id IN (' . implode(
',', array_map(
'intval', $ids)) .
') AND trash = 0';
1459 $count = (int)$this->db()->count($this->dd(
'shopProduct'), $where);
1463 $this->db()->update(
1464 $this->dd(
'shopProduct'),
1465 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
1474 $ids = $this->normalizeProductIds($ids);
1475 $channelKey = trim($channelKey);
1476 if ($ids === array() || !$this->channelExists($channelKey)) {
1481 foreach ($ids as $id) {
1482 $ok = $this->db()->save(
1483 $this->dd(
'shopProductChannel'),
1484 array(
'product_id' => $id,
'channel_key' => $channelKey,
'active' => 1,
'channel_sku' =>
'',
'price_gross' => -1,
'shipping_gross' => -1),
1485 'product_id = ' . (
int)$id .
' AND channel_key = ' . $this->sqlValue($channelKey),
1489 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$id .
' AND trash = 0', 0);
1498 $ids = $this->normalizeProductIds($ids);
1499 $channelKey = trim($channelKey);
1500 if ($ids === array() || !$this->channelExists($channelKey)) {
1505 foreach ($ids as $id) {
1506 $ok = $this->db()->save(
1507 $this->dd(
'shopProductChannel'),
1508 array(
'product_id' => $id,
'channel_key' => $channelKey,
'active' => 0,
'channel_sku' =>
'',
'price_gross' => -1,
'shipping_gross' => -1),
1509 'product_id = ' . (
int)$id .
' AND channel_key = ' . $this->sqlValue($channelKey),
1513 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$id .
' AND trash = 0', 0);
1522 $ids = $this->normalizeProductIds($ids);
1523 if ($ids === array() || !$this->productGroupExists($groupId)) {
1528 foreach ($ids as $id) {
1529 $this->db()->update($this->dd(
'shopProductGroupMap'), array(
'is_primary' => 0),
'product_id = ' . (
int)$id, 0);
1530 $ok = $this->db()->save(
1531 $this->dd(
'shopProductGroupMap'),
1532 array(
'product_id' => $id,
'group_id' => $groupId,
'is_primary' => 1),
1533 'product_id = ' . (
int)$id .
' AND group_id = ' . (
int)$groupId,
1537 $this->db()->update($this->dd(
'shopProduct'), array(
'product_group_id' => $groupId,
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$id .
' AND trash = 0', 0);
1545 $product = $this->db()->select1($this->dd(
'shopProduct'),
'id = ' . (int)$productId .
' AND trash = 0',
'product_group_id', 0);
1546 $directGroupId = (int)($product[
'product_group_id'] ?? 0);
1547 if ($directGroupId > 0) {
1548 $group = $this->
groupById($directGroupId);
1549 if (is_array($group)) {
1550 return array($group);
1554 $maps = $this->db()->select($this->dd(
'shopProductGroupMap'),
'product_id = ' . (
int)$productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 0, 0);
1555 if (!is_array($maps) || $maps === array()) {
1558 $groupIds = array_values(array_unique(array_map(fn($row) => (
int)($row[
'group_id'] ?? 0), $maps)));
1559 $groupIds = array_values(array_filter($groupIds, fn($id) => $id > 0));
1560 if ($groupIds === array()) {
1563 $groups = $this->db()->select($this->dd(
'shopProductGroup'),
'id IN (' . implode(
',', $groupIds) .
') AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
1566 $byId[(int)($group[
'id'] ?? 0)] = $group;
1569 foreach ($maps as $map) {
1570 $id = (int)($map[
'group_id'] ?? 0);
1571 if (isset(
$byId[$id])) {
1573 $row[
'_is_primary'] = (int)($map[
'is_primary'] ?? 0);
1577 usort(
$rows, fn($a, $b) => ((
int)($b[
'_is_primary'] ?? 0) <=> (
int)($a[
'_is_primary'] ?? 0))
1578 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
1579 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
1580 foreach (
$rows as &$row) {
1581 unset($row[
'_is_primary']);
1588 $channelIndex = array();
1589 foreach ($this->
channels() as $channel) {
1590 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
1593 $channels = array();
1594 $direct = $this->db()->select($this->dd(
'shopProductChannel'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
1595 foreach ((is_array($direct) ? $direct : array()) as $row) {
1596 $key = (string)($row[
'channel_key'] ??
'');
1597 $base = $channelIndex[$key] ?? array(
'channel_key' => $key,
'title' => $key,
'sorter' => 9999);
1598 $channels[] = array(
1599 'channel_key' => $key,
1600 'title' => (
string)(
$base[
'title'] ?? $key),
1601 'active' => (
int)($row[
'active'] ?? 0),
1602 'channel_sku' => (
string)($row[
'channel_sku'] ??
''),
1603 'price_gross' => (
float)($row[
'price_gross'] ?? -1),
1604 'shipping_gross' => (
float)($row[
'shipping_gross'] ?? -1),
1605 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
1608 usort($channels, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
1609 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
1611 $groupMaps = $this->db()->select($this->dd(
'shopProductChannelGroupMap'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
1612 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
1613 $groupId = (int)($groupMap[
'channel_group_id'] ?? 0);
1614 if ($groupId <= 0)
continue;
1615 $groupChannels = $this->db()->select($this->dd(
'shopChannelGroupChannel'),
'channel_group_id = ' . $groupId .
' AND active = 1',
'*',
'',
'ASC',
'', 0, 0, 0);
1616 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
1617 $key = (string)($row[
'channel_key'] ??
'');
1618 $base = $channelIndex[$key] ?? array(
'channel_key' => $key,
'title' => $key,
'sorter' => 9999);
1619 $channels[] = array(
1620 'channel_key' => $key,
1621 'title' => (
string)(
$base[
'title'] ?? $key),
1623 'channel_sku' =>
'',
1624 'price_gross' => -1,
1625 'shipping_gross' => -1,
1626 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
1633 foreach ($channels as $channel) {
1634 $key = (string)($channel[
'channel_key'] ??
'');
1635 if ($key ===
'' || isset($seen[$key]))
continue;
1637 if ((
int)($channel[
'active'] ?? 0) === 1) {
1638 unset($channel[
'_sorter']);
1639 $clean[] = $channel;
1647 $channelIndex = array();
1648 foreach ($this->
channels() as $channel) {
1649 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
1651 $rows = $this->db()->select($this->dd(
'shopProductChannel'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
1653 foreach (
$rows as &$row) {
1654 $key = (string)($row[
'channel_key'] ??
'');
1655 $row[
'title'] = (string)($channelIndex[$key][
'title'] ?? $key);
1656 $row[
'_sorter'] = (int)($channelIndex[$key][
'sorter'] ?? 9999);
1659 usort(
$rows, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
1660 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
1662 foreach (
$rows as $row) {
1663 $key = (string)($row[
'channel_key'] ??
'');
1665 unset($row[
'_sorter']);
1674 $channelIndex = array();
1675 foreach ($this->
channels() as $channel) {
1676 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
1678 $groupMaps = $this->db()->select($this->dd(
'shopProductChannelGroupMap'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
1680 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
1681 $groupId = (int)($groupMap[
'channel_group_id'] ?? 0);
1682 if ($groupId <= 0)
continue;
1683 $group = $this->db()->select1($this->dd(
'shopChannelGroup'),
'id = ' . $groupId .
' AND trash = 0 AND active = 1',
'*', 0);
1684 if (!is_array($group))
continue;
1685 $groupChannels = $this->db()->select($this->dd(
'shopChannelGroupChannel'),
'channel_group_id = ' . $groupId .
' AND active = 1',
'*',
'',
'ASC',
'', 0, 0, 0);
1686 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
1687 $key = (string)($row[
'channel_key'] ??
'');
1688 $base = $channelIndex[$key] ?? array(
'channel_key' => $key,
'title' => $key,
'sorter' => 9999);
1690 'channel_key' => $key,
1691 'title' => (
string)(
$base[
'title'] ?? $key),
1692 'active' => (
int)($row[
'active'] ?? 0),
1693 'group_title' => (
string)($group[
'title'] ??
''),
1694 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
1698 usort(
$rows, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
1699 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
1701 foreach (
$rows as $row) {
1702 $key = (string)($row[
'channel_key'] ??
'');
1706 if (!isset(
$out[$key])) {
1708 $out[$key][
'group_titles'] = array();
1710 $groupTitle = trim((
string)($row[
'group_title'] ??
''));
1711 if ($groupTitle !==
'') {
1712 $out[$key][
'group_titles'][$groupTitle] = $groupTitle;
1714 unset(
$out[$key][
'_sorter']);
1721 $productId = max(0, $productId);
1722 if ($productId <= 0) {
1726 $product = $this->productById($productId);
1732 foreach ($activeChannelKeys as $key) {
1733 $key = trim((
string)$key);
1735 $active[$key] =
true;
1739 foreach ($this->channels() as $channel) {
1740 $key = trim((
string)($channel[
'channel_key'] ??
''));
1744 $existing = $this->db()->select1(
1745 $this->dd(
'shopProductChannel'),
1746 'product_id = ' . (
int)$productId .
' AND channel_key = ' . $this->sqlValue($key),
1750 $existing = is_array($existing) ? $existing : array();
1751 $channelSku = trim((
string)($existing[
'channel_sku'] ??
''));
1752 if ($channelSku ===
'') {
1753 $channelSku = (string)($product[
'sku'] ??
'');
1756 $this->dd(
'shopProductChannel'),
1758 'product_id' => $productId,
1759 'channel_key' => $key,
1760 'active' => isset($active[$key]) ? 1 : 0,
1761 'channel_sku' => $channelSku,
1762 'price_gross' => (
float)($existing[
'price_gross'] ?? -1),
1763 'shipping_gross' => (
float)($existing[
'shipping_gross'] ?? -1),
1764 'external_listing_id' => (
string)($existing[
'external_listing_id'] ??
''),
1765 'external_offer_id' => (
string)($existing[
'external_offer_id'] ??
''),
1766 'export_status' => (
string)($existing[
'export_status'] ??
''),
1767 'export_message' => (
string)($existing[
'export_message'] ??
''),
1768 'export_payload' => (
string)($existing[
'export_payload'] ??
''),
1769 'last_export_date' => (
string)($existing[
'last_export_date'] ??
''),
1771 'product_id = ' . (
int)$productId .
' AND channel_key = ' . $this->sqlValue($key),
1776 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$productId .
' AND trash = 0', 0);
1780 $maps = $this->db()->select($this->dd(
'shopProductShippingGroupMap'),
'product_id = ' . (int)$productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 0, 0);
1781 if (!is_array($maps) || $maps === array()) {
1784 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (
int)($row[
'shipping_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1785 if ($ids === array()) {
1788 $groups = $this->db()->select($this->dd(
'shopShippingGroup'),
'id IN (' . implode(
',', $ids) .
') AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
1791 $byId[(int)($group[
'id'] ?? 0)] = $group;
1794 foreach ($maps as $map) {
1795 $id = (int)($map[
'shipping_group_id'] ?? 0);
1796 if (isset(
$byId[$id])) {
1798 $row[
'_is_primary'] = (int)($map[
'is_primary'] ?? 0);
1802 usort(
$rows, fn($a, $b) => ((
int)($b[
'_is_primary'] ?? 0) <=> (
int)($a[
'_is_primary'] ?? 0))
1803 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
1804 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
1805 foreach (
$rows as &$row) {
1806 unset($row[
'_is_primary']);
1813 $maps = $this->db()->select($this->dd(
'shopProductChannelGroupMap'),
'product_id = ' . (int)$productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 0, 0);
1814 if (!is_array($maps) || $maps === array()) {
1817 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (
int)($row[
'channel_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1818 if ($ids === array()) {
1821 $groups = $this->db()->select($this->dd(
'shopChannelGroup'),
'id IN (' . implode(
',', $ids) .
') AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
1824 $byId[(int)($group[
'id'] ?? 0)] = $group;
1827 foreach ($maps as $map) {
1828 $id = (int)($map[
'channel_group_id'] ?? 0);
1829 if (isset(
$byId[$id])) {
1831 $row[
'_is_primary'] = (int)($map[
'is_primary'] ?? 0);
1835 usort(
$rows, fn($a, $b) => ((
int)($b[
'_is_primary'] ?? 0) <=> (
int)($a[
'_is_primary'] ?? 0))
1836 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
1837 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
1838 foreach (
$rows as &$row) {
1839 unset($row[
'_is_primary']);
1847 return $this->remember(
'groups',
function(): array {
1848 $rows = $this->db()->select($this->dd(
'shopProductGroup'),
'trash = 0',
'*',
'parent_id ASC, sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1855 $where =
'trash = 0 AND parent_id = ' . max(0, (
int)$parentId);
1857 $where .=
' AND active = 1';
1859 $rows = $this->db()->select($this->dd(
'shopProductGroup'), $where,
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1867 $current = max(0, (
int)$groupId);
1868 while ($current > 0 && !isset($seen[$current])) {
1869 $seen[$current] =
true;
1871 if (!is_array($group)) {
1874 array_unshift($path, $group);
1875 $current = (int)($group[
'parent_id'] ?? 0);
1880 private function wouldCreateGroupCycle(
int $groupId,
int $parentId): bool {
1881 if ($groupId <= 0 || $parentId <= 0) {
1884 if ($groupId === $parentId) {
1887 $seen = array($groupId =>
true);
1888 $current = $parentId;
1889 while ($current > 0 && !isset($seen[$current])) {
1890 $seen[$current] =
true;
1891 $group = $this->groupById($current);
1892 if (!is_array($group)) {
1895 $current = (int)($group[
'parent_id'] ?? 0);
1896 if ($current === $groupId) {
1903 private function nextGroupSorter(
int $parentId): int {
1904 $rows = $this->db()->select(
1905 $this->dd(
'shopProductGroup'),
1906 'trash = 0 AND parent_id = ' . max(0, (int)$parentId),
1915 $max = is_array(
$rows) && isset(
$rows[0]) ? (int)(
$rows[0][
'sorter'] ?? 0) : 0;
1921 $groupId = max(0, (
int)$groupId);
1922 $parentId = max(0, (
int)$parentId);
1923 if ($groupId <= 0 || !is_array($this->
groupById($groupId))) {
1926 if ($parentId > 0 && !is_array($this->groupById($parentId))) {
1929 if ($this->wouldCreateGroupCycle($groupId, $parentId)) {
1932 $this->db()->update(
1933 $this->dd(
'shopProductGroup'),
1935 'parent_id' => $parentId,
1936 'sorter' => $this->nextGroupSorter($parentId),
1937 'update_date' => date(
'Y-m-d H:i:s'),
1939 'id = ' . $groupId .
' AND trash = 0',
1942 $this->clearRequestCache();
1948 return $this->remember(
'channels',
function(): array {
1949 $rows = $this->db()->select($this->dd(
'shopChannel'),
'trash = 0',
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1959 $row = $this->db()->select1($this->dd(
'shopChannel'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
1960 return is_array($row) ? $row :
null;
1969 $row = $this->db()->select1($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($key) .
' AND trash = 0',
'*', 0);
1970 return is_array($row) ? $row :
null;
1975 $secretFields = array(
'api_client_secret',
'api_access_token',
'api_refresh_token',
'api_password',
'webhook_secret');
1976 $existing = $id > 0 ? ($this->
channelById($id) ?: array()) : array();
1978 'title' => (
string)($data[
'title'] ??
''),
1979 'description' => (
string)($data[
'description'] ??
''),
1980 'platform_type' => (
string)($data[
'platform_type'] ??
'custom'),
1981 'connection_mode' => (
string)($data[
'connection_mode'] ??
'manual'),
1982 'api_base_url' => (
string)($data[
'api_base_url'] ??
''),
1983 'api_client_id' => (
string)($data[
'api_client_id'] ??
''),
1984 'api_username' => (
string)($data[
'api_username'] ??
''),
1985 'marketplace_id' => (
string)($data[
'marketplace_id'] ??
''),
1986 'seller_id' => (
string)($data[
'seller_id'] ??
''),
1987 'account_id' => (
string)($data[
'account_id'] ??
''),
1988 'location_key' => (
string)($data[
'location_key'] ??
''),
1989 'category_id' => (
string)($data[
'category_id'] ??
''),
1990 'payment_policy_id' => (
string)($data[
'payment_policy_id'] ??
''),
1991 'fulfillment_policy_id' => (
string)($data[
'fulfillment_policy_id'] ??
''),
1992 'return_policy_id' => (
string)($data[
'return_policy_id'] ??
''),
1993 'notification_destination' => (
string)($data[
'notification_destination'] ??
''),
1994 'notification_topic' => (
string)($data[
'notification_topic'] ??
''),
1995 'api_scope' => (
string)($data[
'api_scope'] ??
''),
1996 'export_enabled' => !empty($data[
'export_enabled']) ? 1 : 0,
1997 'order_import_enabled' => !empty($data[
'order_import_enabled']) ? 1 : 0,
1998 'active' => !empty($data[
'active']) ? 1 : 0,
1999 'sorter' => (
int)($data[
'sorter'] ?? 100),
2001 foreach ($secretFields as
$field) {
2002 $posted = (string)($data[
$field] ??
'');
2003 $values[
$field] = ($id > 0 && $posted ===
'') ? (
string)($existing[
$field] ??
'') : $posted;
2007 $channelKey = $this->normalizeKey((
string)($data[
'channel_key'] ??
''));
2008 if ($channelKey ===
'') {
2009 $channelKey = $this->normalizeKey((
string)($data[
'title'] ??
'channel'));
2011 if ($channelKey ===
'') $channelKey =
'channel';
2012 $channelKey = $this->uniqueChannelKey($channelKey);
2013 $values[
'channel_key'] = $channelKey;
2014 $this->db()->insert($this->dd(
'shopChannel'), $values, 0);
2015 $this->clearRequestCache();
2019 $this->db()->update($this->dd(
'shopChannel'), $values,
'id = ' . (
int)$id, 0);
2020 $this->clearRequestCache();
2029 $channel = $this->channelById($id);
2034 $updated = (int)$this->db()->update(
2035 $this->dd(
'shopChannel'),
2036 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
2037 'id = ' . (
int)$id .
' AND trash = 0',
2044 $this->db()->update(
2045 $this->dd(
'shopProductChannel'),
2046 array(
'active' => 0),
2047 'channel_key = ' . $this->sqlValue((
string)($channel[
'channel_key'] ??
'')),
2050 $this->clearRequestCache();
2058 return array(
'ok' =>
false,
'message' =>
'Channel wurde nicht gefunden.');
2061 $connector =
dbx()->get_include_obj(
'dbxShopChannelConnector',
'dbxShop');
2062 $result = is_object($connector) && method_exists($connector,
'test')
2063 ? (array)$connector->test($channel)
2064 : array(
'ok' =>
false,
'message' =>
'Channel-Connector konnte nicht geladen werden.');
2067 $message = (string)(
$result[
'message'] ??
'');
2068 $this->saveChannelTestResult($id,
$ok, $message);
2069 return array(
'ok' =>
$ok,
'message' => $message);
2072 private function productHasActiveChannel(array $product,
string $channelKey): bool {
2073 foreach ((array)($product[
'channels'] ?? array()) as $channel) {
2074 if ((
string)($channel[
'channel_key'] ??
'') === $channelKey && (
int)($channel[
'active'] ?? 0) === 1) {
2081 private function productChannelRowForExport(array $product,
string $channelKey): array {
2082 $productId = (int)($product[
'id'] ?? 0);
2083 $row = $this->db()->select1(
2084 $this->dd(
'shopProductChannel'),
2085 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
2089 if (is_array($row)) {
2094 'product_id' => $productId,
2095 'channel_key' => $channelKey,
2097 'channel_sku' => (
string)($product[
'sku'] ??
''),
2098 'price_gross' => -1,
2099 'shipping_gross' => -1,
2100 'export_status' =>
'ready',
2103 $this->dd(
'shopProductChannel'),
2105 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
2108 $row = $this->db()->select1(
2109 $this->dd(
'shopProductChannel'),
2110 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
2114 return is_array($row) ? $row : $values;
2123 $channel = $this->channelByKey($channelKey);
2127 $row = $this->db()->select1(
2128 $this->dd(
'shopProductChannel'),
2129 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
2133 if (!is_array($row)) {
2135 'product_id' => $productId,
2136 'channel_key' => $channelKey,
2137 'active' => $this->productHasActiveChannel($product, $channelKey) ? 1 : 0,
2138 'channel_sku' => (
string)($product[
'sku'] ??
''),
2139 'price_gross' => -1,
2140 'shipping_gross' => -1,
2144 $note = trim((
string)($row[
'note'] ??
''));
2145 $mapping = $note !==
'' ? json_decode($note,
true) : array();
2146 if (!is_array($mapping)) {
2150 'product' => $product,
2151 'channel' => $channel,
2152 'product_channel' => $row,
2153 'mapping' => $mapping,
2161 if (!$product || !$channel) {
2165 $mapping = is_array($data[
'mapping'] ??
null) ? $data[
'mapping'] : array();
2166 $note = json_encode($mapping, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
2167 if ($note ===
false) {
2171 $existing = $this->productChannelRowForExport($product, $channelKey);
2173 $this->dd(
'shopProductChannel'),
2175 'product_id' => $productId,
2176 'channel_key' => $channelKey,
2177 'active' => !empty($data[
'active']) ? 1 : 0,
2178 'channel_sku' => trim((
string)($data[
'channel_sku'] ?? $product[
'sku'] ??
'')),
2179 'price_gross' => (
float)($data[
'price_gross'] ?? -1),
2180 'shipping_gross' => (
float)($data[
'shipping_gross'] ?? -1),
2181 'external_listing_id' => trim((
string)($data[
'external_listing_id'] ?? $existing[
'external_listing_id'] ??
'')),
2182 'external_offer_id' => trim((
string)($data[
'external_offer_id'] ?? $existing[
'external_offer_id'] ??
'')),
2183 'export_status' => (
string)($existing[
'export_status'] ??
''),
2184 'export_message' => (
string)($existing[
'export_message'] ??
''),
2185 'export_payload' => (
string)($existing[
'export_payload'] ??
''),
2186 'last_export_date' => (
string)($existing[
'last_export_date'] ??
''),
2189 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
2192 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . $productId .
' AND trash = 0', 0);
2195 private function saveProductChannelExportResult(
int $productId,
string $channelKey, array
$result): void {
2196 $payload =
$result[
'payload'] ?? array();
2197 $payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
2198 if ($payloadJson ===
false) {
2202 $this->dd(
'shopProductChannel'),
2204 'product_id' => $productId,
2205 'channel_key' => $channelKey,
2207 'external_listing_id' => (
string)(
$result[
'external_listing_id'] ??
''),
2208 'external_offer_id' => (
string)(
$result[
'external_offer_id'] ??
''),
2209 'export_status' => (
string)(
$result[
'status'] ?? (!empty(
$result[
'ok']) ?
'exported' :
'failed')),
2210 'export_message' => (
string)(
$result[
'message'] ??
''),
2211 'export_payload' => $payloadJson,
2212 'last_export_date' => date(
'Y-m-d H:i:s'),
2214 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
2217 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . $productId .
' AND trash = 0', 0);
2222 $channelKey = trim($channelKey);
2225 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Artikel wurde nicht gefunden.');
2227 $channel = $this->channelByKey($channelKey);
2228 if (!$channel || (
int)($channel[
'active'] ?? 0) !== 1) {
2229 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Channel ist nicht aktiv oder wurde nicht gefunden.');
2231 if ((
int)($channel[
'export_enabled'] ?? 0) !== 1) {
2232 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Export ist fuer diesen Channel nicht aktiv.');
2234 if (!$this->productHasActiveChannel($product, $channelKey)) {
2235 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Artikel ist diesem Channel nicht aktiv zugeordnet.');
2238 $productChannel = $this->productChannelRowForExport($product, $channelKey);
2239 $connector =
dbx()->get_include_obj(
'dbxShopChannelConnector',
'dbxShop');
2240 $result = is_object($connector) && method_exists($connector,
'exportProduct')
2241 ? (array)$connector->exportProduct($channel, $product, $productChannel)
2242 : array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Channel-Export-Connector konnte nicht geladen werden.');
2243 $this->saveProductChannelExportResult((
int)$product[
'id'], $channelKey,
$result);
2249 $ids = $this->normalizeProductIds($ids);
2250 $summary = array(
'total' => count($ids),
'ok' => 0,
'failed' => 0,
'messages' => array());
2251 foreach ($ids as $id) {
2256 $summary[
'failed']++;
2258 $message = trim((
string)(
$result[
'message'] ??
''));
2259 if ($message !==
'') {
2260 $summary[
'messages'][] =
'#' . $id .
': ' . $message;
2266 private function saveChannelTestResult(
int $id,
bool $ok,
string $message): void {
2267 $now = date(
'Y-m-d H:i:s');
2268 $this->db()->update(
2269 $this->dd(
'shopChannel'),
2271 'test_status' =>
$ok ?
'ok' :
'error',
2272 'test_message' => $message,
2273 'last_test_date' =>
$now,
2274 'update_date' =>
$now,
2283 $rows = $this->db()->select($this->dd(
'shopShippingGroup'),
'trash = 0',
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
2289 $groups = $this->db()->select($this->dd(
'shopChannelGroup'),
'trash = 0',
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
2291 foreach (
$groups as &$group) {
2299 $channelIndex = array();
2300 foreach ($this->
channels() as $channel) {
2301 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
2303 $rows = $this->db()->select($this->dd(
'shopChannelGroupChannel'),
'channel_group_id = ' . (
int)$channelGroupId,
'*',
'',
'ASC',
'', 0, 0, 0);
2305 foreach (
$rows as &$row) {
2306 $key = (string)($row[
'channel_key'] ??
'');
2307 $row[
'title'] = (string)($channelIndex[$key][
'title'] ?? $key);
2308 $row[
'_sorter'] = (int)($channelIndex[$key][
'sorter'] ?? 9999);
2311 usort(
$rows, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
2312 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
2313 foreach (
$rows as &$row) {
2314 unset($row[
'_sorter']);
2322 $parentId = max(0, (
int)($data[
'parent_id'] ?? 0));
2323 if ($id > 0 && $parentId === $id) {
2327 'parent_id' => $parentId,
2328 'title' => (
string)($data[
'title'] ??
''),
2329 'description' => (
string)($data[
'description'] ??
''),
2330 'tax_class' => (
string)($data[
'tax_class'] ??
'mwst1'),
2331 'default_tax_rate' => $this->taxRateForClass((
string)($data[
'tax_class'] ??
'mwst1'), (
float)($data[
'default_tax_rate'] ?? 19)),
2332 'display_variant' => (
string)($data[
'display_variant'] ??
'gallery_grid'),
2333 'card_template' => (
string)($data[
'card_template'] ??
'product-card-default'),
2334 'detail_template' => (
string)($data[
'detail_template'] ??
'product-detail-default'),
2335 'gallery_template' => (
string)($data[
'gallery_template'] ??
'image-gallery'),
2336 'gallery_visible_count' => max(1, (
int)($data[
'gallery_visible_count'] ?? 3)),
2337 'gallery_image_size' => (
string)($data[
'gallery_image_size'] ??
'original'),
2338 'gallery_lightbox_width' => (
string)($data[
'gallery_lightbox_width'] ??
'100vw'),
2339 'gallery_overflow' => (
string)($data[
'gallery_overflow'] ??
'grid'),
2340 'gallery_click' => (
string)($data[
'gallery_click'] ??
'lightbox'),
2341 'attribute_notes' => (
string)($data[
'attribute_notes'] ??
''),
2342 'ebay_category_id' => (
string)($data[
'ebay_category_id'] ??
''),
2343 'amazon_product_type' => (
string)($data[
'amazon_product_type'] ??
''),
2344 'kleinanzeigen_category_id' => (
string)($data[
'kleinanzeigen_category_id'] ??
''),
2345 'mobile_category_id' => (
string)($data[
'mobile_category_id'] ??
''),
2346 'active' => !empty($data[
'active']) ? 1 : 0,
2347 'sorter' => (
int)($data[
'sorter'] ?? 100),
2350 $groupKey = $this->normalizeKey((
string)($data[
'group_key'] ??
''));
2351 if ($groupKey ===
'') {
2352 $groupKey = $this->normalizeKey((
string)($data[
'title'] ??
'artikelgruppe'));
2354 if ($groupKey ===
'') $groupKey =
'artikelgruppe';
2355 $groupKey = $this->uniqueProductGroupKey($groupKey);
2356 $values[
'group_key'] = $groupKey;
2357 $this->db()->insert($this->dd(
'shopProductGroup'), $values, 0);
2358 $this->clearRequestCache();
2362 $this->db()->update($this->dd(
'shopProductGroup'), $values,
'id = ' . (
int)$id, 0);
2363 $this->clearRequestCache();
2369 'title' => (
string)($data[
'title'] ??
''),
2370 'description' => (
string)($data[
'description'] ??
''),
2371 'shipping_way' => (
string)($data[
'shipping_way'] ??
''),
2372 'delivery_time' => (
string)($data[
'delivery_time'] ??
''),
2373 'shipping_gross' => (
float)($data[
'shipping_gross'] ?? 0),
2374 'free_from_gross' => (
float)($data[
'free_from_gross'] ?? -1),
2375 'active' => !empty($data[
'active']) ? 1 : 0,
2376 'sorter' => (
int)($data[
'sorter'] ?? 100),
2379 $groupKey = $this->normalizeKey((
string)($data[
'group_key'] ??
''));
2380 if ($groupKey ===
'') {
2381 $groupKey = $this->normalizeKey((
string)($data[
'title'] ??
'versandgruppe'));
2383 if ($groupKey ===
'') $groupKey =
'versandgruppe';
2384 $groupKey = $this->uniqueShippingGroupKey($groupKey);
2385 $values[
'group_key'] = $groupKey;
2386 $this->db()->insert($this->dd(
'shopShippingGroup'), $values, 0);
2390 $this->db()->update($this->dd(
'shopShippingGroup'), $values,
'id = ' . (
int)$id, 0);
2396 'title' => (
string)($data[
'title'] ??
''),
2397 'description' => (
string)($data[
'description'] ??
''),
2398 'active' => !empty($data[
'active']) ? 1 : 0,
2399 'sorter' => (
int)($data[
'sorter'] ?? 100),
2402 $groupKey = $this->normalizeKey((
string)($data[
'group_key'] ??
''));
2403 if ($groupKey ===
'') {
2404 $groupKey = $this->normalizeKey((
string)($data[
'title'] ??
'channel-gruppe'));
2406 if ($groupKey ===
'') $groupKey =
'channel-gruppe';
2407 $groupKey = $this->uniqueChannelGroupKey($groupKey);
2408 $values[
'group_key'] = $groupKey;
2409 if ($values[
'title'] ===
'') {
2410 $values[
'title'] = $groupKey;
2412 $this->db()->insert($this->dd(
'shopChannelGroup'), $values, 0);
2413 $row = $this->db()->select1($this->dd(
'shopChannelGroup'),
'group_key = ' . $this->sqlValue($groupKey),
'id', 0);
2414 $id = (int)($row[
'id'] ?? 0);
2416 $this->db()->update($this->dd(
'shopChannelGroup'), $values,
'id = ' . (
int)$id, 0);
2422 $channels = $this->channels();
2423 foreach ($channels as $channel) {
2424 $key = (string)($channel[
'channel_key'] ??
'');
2425 if ($key ===
'')
continue;
2426 $active = in_array($key, $channelKeys,
true) ? 1 : 0;
2428 $this->dd(
'shopChannelGroupChannel'),
2429 array(
'channel_group_id' => $id,
'channel_key' => $key,
'active' => $active),
2430 'channel_group_id = ' . (
int)$id .
' AND channel_key = ' . $this->sqlValue($key),
2442 $updated = (int)$this->db()->update(
2443 $this->dd(
'shopChannelGroup'),
2444 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
2445 'id = ' . (
int)$id .
' AND trash = 0',
2452 $this->db()->update($this->dd(
'shopChannelGroupChannel'), array(
'active' => 0),
'channel_group_id = ' . (
int)$id, 0);
2462 $updated = (int)$this->db()->update(
2463 $this->dd(
'shopProductGroup'),
2464 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
2465 'id = ' . (
int)$id .
' AND trash = 0',
2468 $this->clearRequestCache();
2478 return (
int)$this->db()->update(
2479 $this->dd(
'shopShippingGroup'),
2480 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
2481 'id = ' . (
int)$id .
' AND trash = 0',
2486 private function normalizeKey(
string $value): string {
2493 private function uniqueProductGroupKey(
string $baseKey): string {
2494 return $this->uniqueKey(
'shop_product_group', $baseKey);
2497 private function uniqueShippingGroupKey(
string $baseKey): string {
2498 return $this->uniqueKey(
'shop_shipping_group', $baseKey);
2501 private function uniqueChannelKey(
string $baseKey): string {
2502 $key = $baseKey !==
'' ? $baseKey :
'channel';
2505 if ($this->db()->count($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($key)) <= 0)
return $key;
2506 $key = $baseKey .
'-' . $suffix;
2511 private function uniqueChannelGroupKey(
string $baseKey): string {
2512 return $this->uniqueKey(
'shop_channel_group', $baseKey);
2515 private function uniqueKey(
string $table,
string $baseKey): string {
2517 'shop_product_group' => $this->dd(
'shopProductGroup'),
2518 'shop_shipping_group' => $this->dd(
'shopShippingGroup'),
2519 'shop_channel_group' => $this->dd(
'shopChannelGroup'),
2521 if (!isset($ddMap[
$table])) {
2524 $key = $baseKey !==
'' ? $baseKey :
'gruppe';
2527 if ($this->db()->count($ddMap[
$table],
'group_key = ' . $this->sqlValue($key)) <= 0)
return $key;
2528 $key = $baseKey .
'-' . $suffix;
2536 if ($productId > 0) {
2537 $rows = $this->db()->select(
2538 $this->dd(
'shopProductImage'),
2539 'trash = 0 AND active = 1 AND product_id = ' . (
int)$productId,
2541 'is_primary DESC, sorter ASC, title ASC',
2551 $groupIds = array();
2553 $id = (int)($group[
'id'] ?? 0);
2554 if ($id > 0) $groupIds[] = $id;
2556 if ($groupIds !== array()) {
2557 $rows = $this->db()->select(
2558 $this->dd(
'shopProductImage'),
2559 'trash = 0 AND active = 1 AND group_id IN (' . implode(
',', array_map(
'intval', $groupIds)) .
')',
2561 'is_primary DESC, sorter ASC, title ASC',
2568 $images = array_merge($images, is_array(
$rows) ?
$rows : array());
2573 foreach ($images as $image) {
2574 $path = (string)($image[
'image_path'] ??
'');
2575 $mediaId = (int)($image[
'media_id'] ?? 0);
2577 if ((
$mediaId <= 0 && $path ===
'') || isset($seen[$key]))
continue;
2587 $products = $this->db()->select($this->dd(
'shopProduct'),
'trash = 0',
'id',
'',
'ASC',
'', 0, 0, 0);
2588 foreach ((is_array($products) ? $products : array()) as $product) {
2589 $productId = (int)($product[
'id'] ?? 0);
2590 if ($productId <= 0)
continue;
2599 $rows = $this->db()->select($this->dd(
'shopProductImage'),
'trash = 0',
'*',
'active DESC, product_id DESC, group_id DESC, sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
2602 $productIds = array_values(array_unique(array_filter(array_map(
2603 static fn($row) => (
int)($row[
'product_id'] ?? 0),
2606 $groupIds = array_values(array_unique(array_filter(array_map(
2607 static fn($row) => (
int)($row[
'group_id'] ?? 0),
2610 $productById = array();
2611 if ($productIds !== array()) {
2612 $products = $this->db()->select(
2613 $this->dd(
'shopProduct'),
2614 'id IN (' . implode(
',', array_map(
'intval', $productIds)) .
')',
2623 $productById = $this->rowsById(is_array($products) ? $products : array());
2625 $groupById = array();
2626 if ($groupIds !== array()) {
2627 $groups = $this->db()->select(
2628 $this->dd(
'shopProductGroup'),
2629 'id IN (' . implode(
',', array_map(
'intval', $groupIds)) .
')',
2638 $groupById = $this->rowsById(is_array(
$groups) ?
$groups : array());
2641 foreach (
$rows as &$row) {
2642 $productId = (int)($row[
'product_id'] ?? 0);
2643 $groupId = (int)($row[
'group_id'] ?? 0);
2644 $row[
'product_title'] = (string)($productById[$productId][
'title'] ??
'');
2645 $row[
'group_title'] = (string)($groupById[$groupId][
'title'] ??
'');
2653 $imageId = max(0, $imageId);
2655 if ($imageId <= 0 ||
$mediaId <= 0) {
2659 $imagePath = trim(str_replace(
'\\',
'/', $imagePath));
2660 if ($imagePath ===
'') {
2661 $imagePath =
'dbxmedia:' .
$mediaId;
2664 $this->db()->update(
2665 $this->dd(
'shopProductImage'),
2666 array(
'media_id' =>
$mediaId,
'image_path' => $imagePath),
2667 'id = ' . (
int)$imageId,
2672 public function saveImage(
int $productId,
int $groupId,
string $imagePath,
string $title,
string $alt,
int $isPrimary = 0,
int $sorter = 100): void {
2674 $productId = max(0, $productId);
2675 $groupId = max(0, $groupId);
2676 $imagePath = trim(str_replace(
'\\',
'/', $imagePath));
2677 if ($imagePath ===
'' || ($productId <= 0 && $groupId <= 0)) {
2680 if ($groupId > 0 && $productId <= 0) {
2681 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1),
'group_id = ' . (
int)$groupId .
' AND product_id = 0 AND trash = 0', 0);
2685 $this->dd(
'shopProductImage'),
2687 'product_id' => $productId,
2688 'group_id' => $groupId,
2689 'image_path' => $imagePath,
2692 'is_primary' => $isPrimary,
2694 'sorter' => $sorter,
2696 'product_id = ' . (
int)$productId .
' AND group_id = ' . (
int)$groupId .
' AND image_path = ' . $this->sqlValue($imagePath),
2701 public function saveMediaImage(
int $productId,
int $groupId,
int $mediaId,
string $title =
'',
string $alt =
'',
int $isPrimary = 0,
int $sorter = 100): ?array {
2703 $productId = max(0, $productId);
2704 $groupId = max(0, $groupId);
2706 if (
$mediaId <= 0 || ($productId <= 0 && $groupId <= 0)) {
2709 if ($groupId > 0 && $productId <= 0) {
2710 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1),
'group_id = ' . (
int)$groupId .
' AND product_id = 0 AND trash = 0', 0);
2716 $this->dd(
'shopProductImage'),
2718 'product_id' => $productId,
2719 'group_id' => $groupId,
2721 'image_path' => $pathKey,
2724 'is_primary' => $isPrimary,
2726 'sorter' => $sorter,
2728 'product_id = ' . (
int)$productId .
' AND group_id = ' . (
int)$groupId .
' AND image_path = ' . $this->sqlValue($pathKey),
2732 $data = $this->db()->select1(
2733 $this->dd(
'shopProductImage'),
2734 'product_id = ' . (
int)$productId .
' AND group_id = ' . (
int)$groupId .
' AND image_path = ' . $this->sqlValue($pathKey),
2738 return is_array($data) ? $data :
null;
2743 if ($groupId <= 0) {
2746 $rows = $this->db()->select(
2747 $this->dd(
'shopProductImage'),
2748 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . (
int)$groupId,
2750 'is_primary DESC, sorter ASC, title ASC',
2758 return is_array($row) ? $row :
null;
2763 $imageId = max(0, $imageId);
2764 $productId = max(0, $productId);
2765 if ($imageId <= 0) {
2769 $where =
'id = ' . (int)$imageId;
2770 if ($productId > 0) {
2771 $groupMaps = $this->db()->select($this->dd(
'shopProductGroupMap'),
'product_id = ' . (
int)$productId,
'group_id',
'',
'ASC',
'', 0, 0, 0);
2772 $groupIds = array();
2773 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
2774 $groupId = (int)($groupMap[
'group_id'] ?? 0);
2775 if ($groupId > 0) $groupIds[$groupId] = $groupId;
2777 $parts = array(
'product_id = ' . (
int)$productId);
2778 if ($groupIds !== array()) {
2779 $parts[] =
'group_id IN (' . implode(
',', array_map(
'intval', $groupIds)) .
')';
2781 $where .=
' AND (' . implode(
' OR ', $parts) .
')';
2784 if ($this->db()->count($this->dd(
'shopProductImage'), $where) <= 0) {
2787 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1), $where, 0);
2791 public function createOrderFromItems(array
$items,
string $channelKey =
'shop',
string $customerName =
'',
string $customerEmail =
'',
string $note =
'',
string $paymentProvider =
'',
string $paymentStatus =
'open',
string $status =
'payment_pending',
string $customerPhone =
'',
string $shippingAddress =
'',
string $legalSnapshot =
'',
string $withdrawalSnapshot =
''): ?array {
2793 if (
$items === array()) {
2797 $now = date(
'Y-m-d H:i:s');
2798 $orderNo =
'S' . date(
'YmdHis') .
'-' . random_int(1000, 9999);
2799 $uid = function_exists(
'dbx') ? (int)
dbx()->user() : 0;
2800 $allowedStatus = array(
'new',
'payment_pending',
'paid',
'processing',
'shipped',
'done',
'cancelled');
2801 $allowedPayment = array(
'open',
'created',
'pending',
'completed',
'paid',
'failed',
'cancelled',
'refunded');
2802 if (!in_array($status, $allowedStatus,
true)) {
2803 $status =
'payment_pending';
2805 if (!in_array($paymentStatus, $allowedPayment,
true)) {
2806 $paymentStatus =
'open';
2809 $snapshots = array();
2811 foreach (
$items as $sku => $qty) {
2812 $qty = max(1, (
int)$qty);
2813 $product = $this->productBySku((
string)$sku);
2814 if (!$product)
continue;
2815 $price = (float)($product[
'price_gross'] ?? 0);
2816 $shipping = (float)($product[
'effective_shipping_gross'] ?? 0);
2817 $line = ($price + $shipping) * $qty;
2819 $snapshots[] = array(
2820 'product_id' => (
int)($product[
'id'] ?? 0),
2821 'product_type' => (
string)($product[
'product_type'] ??
''),
2822 'sku' => (
string)($product[
'sku'] ?? $sku),
2823 'title' => (
string)($product[
'title'] ??
''),
2825 'price_gross' => $price,
2826 'tax_rate' => (
float)($product[
'effective_tax_rate'] ?? 0),
2827 'shipping_gross' => $shipping,
2828 'total_gross' => $line,
2832 if ($snapshots === array()) {
2835 $stockReserved = $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2838 if (
$db->begin($this->dd(
'shopOrder')) !== 1) {
2839 throw new \RuntimeException(
'Bestelltransaktion konnte nicht gestartet werden.');
2843 $orderOk = (int)
$db->insert($this->dd(
'shopOrder'), array(
2844 'create_date' =>
$now,
2845 'update_date' =>
$now,
2846 'order_no' => $orderNo,
2848 'status' => $status,
2849 'customer_name' => $customerName,
2850 'customer_email' => $customerEmail,
2851 'customer_phone' => $customerPhone,
2852 'shipping_address' => $shippingAddress,
2853 'total_gross' => $total,
2854 'currency' =>
'EUR',
2855 'channel_key' => $channelKey,
2856 'payment_provider' => $paymentProvider,
2857 'payment_status' => $paymentStatus,
2858 'stock_reserved' => $stockReserved,
2859 'legal_snapshot' => $legalSnapshot,
2860 'withdrawal_snapshot' => $withdrawalSnapshot,
2863 if ($orderOk !== 1) {
2864 throw new \RuntimeException(
'order_insert_failed');
2866 $orderId = (int)
$db->get_insert_id();
2867 if ($orderId <= 0) {
2868 throw new \RuntimeException(
'order_id_missing');
2871 foreach ($snapshots as $item) {
2872 if (
$db->insert($this->dd(
'shopOrderItem'), array(
2873 'create_date' =>
$now,
2874 'update_date' =>
$now,
2875 'order_id' => $orderId,
2876 'product_id' => $item[
'product_id'],
2877 'sku' => $item[
'sku'],
2878 'title' => $item[
'title'],
2879 'qty' => $item[
'qty'],
2880 'price_gross' => $item[
'price_gross'],
2881 'tax_rate' => $item[
'tax_rate'],
2882 'shipping_gross' => $item[
'shipping_gross'],
2883 'total_gross' => $item[
'total_gross'],
2885 throw new \RuntimeException(
'order_item_insert_failed');
2889 $reserved = $stockReserved === 1 ? $this->reserveStockForSnapshots($snapshots) : 0;
2890 if ($stockReserved === 1 && $reserved <= 0) {
2891 throw new \RuntimeException(
'stock_reservation_failed');
2893 if (!$this->addOrderHistory($orderId,
'created',
'', $status,
'Bestellung wurde angelegt.')) {
2894 throw new \RuntimeException(
'order_history_insert_failed');
2896 if (
$db->commit($this->dd(
'shopOrder')) !== 1) {
2897 throw new \RuntimeException(
'order_commit_failed');
2899 }
catch (\Throwable $e) {
2900 $db->rollback($this->dd(
'shopOrder'));
2901 if (str_starts_with($e->getMessage(),
'Nicht genuegend Lagerbestand')) {
2904 dbx()->debug(
'#Shop order rollback error=(' . $e->getMessage() .
')');
2908 return $this->orderByNo($orderNo);
2914 if (!$channel || (
int)($channel[
'order_import_enabled'] ?? 0) !== 1 || (
int)($channel[
'active'] ?? 0) !== 1) {
2915 throw new \RuntimeException(
'Order-Import fuer diesen Channel ist nicht aktiv.');
2918 $externalId = trim((
string)($payload[
'order_id'] ?? $payload[
'external_order_id'] ?? $payload[
'id'] ??
''));
2919 if ($externalId ===
'') {
2920 throw new \RuntimeException(
'Payload enthaelt keine externe Bestellnummer.');
2923 $paymentStatus = strtolower((
string)($payload[
'payment_status'] ?? $payload[
'status'] ??
'completed'));
2924 $normalizedPayment = in_array($paymentStatus, array(
'paid',
'completed',
'captured'),
true)
2926 : (in_array($paymentStatus, array(
'cancelled',
'canceled',
'voided'),
true) ?
'cancelled' :
'pending');
2928 $existing = $this->orderByPaymentReference($channelKey, $externalId);
2930 $this->updateOrderPayment((
int)$existing[
'id'], $channelKey, $normalizedPayment, $externalId, $payload);
2931 return $this->orderByNo((
string)$existing[
'order_no']);
2934 $items = is_array($payload[
'items'] ??
null) ? $payload[
'items'] : array();
2935 if (
$items === array()) {
2936 throw new \RuntimeException(
'Payload enthaelt keine Positionen.');
2939 $now = date(
'Y-m-d H:i:s');
2940 $orderNo =
'C' . date(
'YmdHis') .
'-' . random_int(1000, 9999);
2941 $customer = is_array($payload[
'customer'] ??
null) ? $payload[
'customer'] : array();
2942 $customerName = (string)($payload[
'customer_name'] ?? $customer[
'name'] ??
'');
2943 $customerEmail = (string)($payload[
'customer_email'] ?? $customer[
'email'] ??
'');
2944 $customerPhone = (string)($payload[
'customer_phone'] ?? $customer[
'phone'] ??
'');
2945 $shipping = is_array($payload[
'shipping_address'] ??
null) ? $payload[
'shipping_address'] : (is_array($payload[
'shipping'] ??
null) ? $payload[
'shipping'] : array());
2946 $shippingAddress = trim((
string)($payload[
'shipping_address_text'] ?? $payload[
'address'] ??
''));
2947 if ($shippingAddress ===
'' && $shipping !== array()) {
2948 $shippingAddress = trim(implode(
"\n", array_filter(array_map(
'strval', array(
2949 $shipping[
'name'] ?? $customerName,
2950 $shipping[
'street'] ?? $shipping[
'address1'] ??
'',
2951 $shipping[
'address2'] ??
'',
2952 trim((
string)($shipping[
'zip'] ?? $shipping[
'postal_code'] ??
'') .
' ' . (
string)($shipping[
'city'] ??
'')),
2953 $shipping[
'country'] ?? $shipping[
'country_code'] ??
'',
2956 $currency = (string)($payload[
'currency'] ??
'EUR');
2957 $snapshots = array();
2960 foreach (
$items as $item) {
2961 if (!is_array($item)) {
2964 $sku = (string)($item[
'sku'] ?? $item[
'seller_sku'] ?? $item[
'item_sku'] ??
'');
2965 $qty = max(1, (
int)($item[
'qty'] ?? $item[
'quantity'] ?? 1));
2966 $product = $sku !==
'' ? $this->productBySku($sku,
false) : null;
2967 $price = (float)($item[
'price_gross'] ?? $item[
'price'] ?? $item[
'unit_price'] ?? ($product[
'price_gross'] ?? 0));
2968 $shipping = (float)($item[
'shipping_gross'] ?? $item[
'shipping'] ?? 0);
2969 $lineTotal = (float)($item[
'total_gross'] ?? $item[
'total'] ?? (($price + $shipping) * $qty));
2970 $total += $lineTotal;
2971 $snapshots[] = array(
2972 'product_id' => (
int)($product[
'id'] ?? 0),
2973 'product_type' => (
string)($product[
'product_type'] ??
''),
2975 'title' => (
string)($item[
'title'] ?? $item[
'name'] ?? $product[
'title'] ?? $sku),
2977 'price_gross' => $price,
2978 'tax_rate' => (
float)($item[
'tax_rate'] ?? $product[
'effective_tax_rate'] ?? 0),
2979 'shipping_gross' => $shipping,
2980 'total_gross' => $lineTotal,
2984 if ($snapshots === array()) {
2985 throw new \RuntimeException(
'Payload enthaelt keine verwertbaren Positionen.');
2987 if (isset($payload[
'total_gross']) || isset($payload[
'total']) || isset($payload[
'amount'])) {
2988 $total = (float)($payload[
'total_gross'] ?? $payload[
'total'] ?? $payload[
'amount']);
2990 $stockReserved = $normalizedPayment !==
'cancelled' && $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2993 if (
$db->begin($this->dd(
'shopOrder')) !== 1) {
2994 throw new \RuntimeException(
'Channel-Bestelltransaktion konnte nicht gestartet werden.');
3002 $channelId = (int)($channel[
'id'] ?? 0);
3003 $channelDd = $this->dd(
'shopChannel');
3004 $channelServer =
$db->get_dd_server($channelDd);
3005 $channelTable =
$db->get_dd_table($channelDd);
3006 $lockResult = $channelId > 0
3007 ?
$db->update_query(
3009 'UPDATE ' . $channelTable .
' SET id = id WHERE id = ' . $channelId .
' AND trash = 0'
3012 $lockedChannel = $lockResult >= 0
3013 ?
$db->select1($channelDd,
'id = ' . $channelId .
' AND trash = 0',
'id', 0)
3015 if (!is_array($lockedChannel) || (
int)($lockedChannel[
'id'] ?? 0) !== $channelId) {
3016 throw new \RuntimeException(
'channel_import_lock_failed');
3020 $duplicate =
$db->select1(
3021 $this->dd(
'shopOrder'),
3022 'payment_provider = ' . $this->sqlValue($channelKey)
3023 .
' AND payment_reference = ' . $this->sqlValue($externalId)
3028 if (is_array($duplicate) && (
int)($duplicate[
'id'] ?? 0) > 0) {
3029 $db->rollback($this->dd(
'shopOrder'));
3030 return $this->orderByNo((
string)$duplicate[
'order_no']);
3033 if (
$db->insert($this->dd(
'shopOrder'), array(
3034 'create_date' =>
$now,
3035 'update_date' =>
$now,
3036 'order_no' => $orderNo,
3038 'status' => $normalizedPayment ===
'completed' ?
'paid' : ($normalizedPayment ===
'cancelled' ?
'cancelled' :
'payment_pending'),
3039 'customer_name' => $customerName,
3040 'customer_email' => $customerEmail,
3041 'customer_phone' => $customerPhone,
3042 'shipping_address' => $shippingAddress,
3043 'total_gross' => $total,
3044 'currency' => $currency,
3045 'channel_key' => $channelKey,
3046 'payment_provider' => $channelKey,
3047 'payment_status' => $normalizedPayment,
3048 'stock_reserved' => $stockReserved,
3049 'payment_reference' => $externalId,
3050 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
3052 throw new \RuntimeException(
'channel_order_insert_failed');
3054 $orderId = (int)
$db->get_insert_id();
3055 if ($orderId <= 0)
throw new \RuntimeException(
'channel_order_id_missing');
3057 foreach ($snapshots as $item) {
3058 if (
$db->insert($this->dd(
'shopOrderItem'), array(
3059 'create_date' =>
$now,
3060 'update_date' =>
$now,
3061 'order_id' => $orderId,
3062 'product_id' => $item[
'product_id'],
3063 'sku' => $item[
'sku'],
3064 'title' => $item[
'title'],
3065 'qty' => $item[
'qty'],
3066 'price_gross' => $item[
'price_gross'],
3067 'tax_rate' => $item[
'tax_rate'],
3068 'shipping_gross' => $item[
'shipping_gross'],
3069 'total_gross' => $item[
'total_gross'],
3071 throw new \RuntimeException(
'channel_order_item_insert_failed');
3074 $reserved = $stockReserved === 1 ? $this->reserveStockForSnapshots($snapshots) : 0;
3075 if ($stockReserved === 1 && $reserved <= 0) {
3076 throw new \RuntimeException(
'channel_stock_reservation_failed');
3078 if (!$this->addOrderHistory($orderId,
'channel_import',
'', $normalizedPayment,
'Bestellung wurde ueber Channel ' . $channelKey .
' importiert.')) {
3079 throw new \RuntimeException(
'channel_order_history_failed');
3081 if (
$db->commit($this->dd(
'shopOrder')) !== 1) {
3082 throw new \RuntimeException(
'channel_order_commit_failed');
3084 }
catch (\Throwable $e) {
3085 $db->rollback($this->dd(
'shopOrder'));
3086 dbx()->debug(
'#Shop channel order rollback reference=(' . $externalId .
') error=(' . $e->getMessage() .
')');
3090 return $this->orderByNo($orderNo);
3095 $row = $this->db()->select1($this->dd(
'shopOrder'),
'order_no = ' . $this->sqlValue($orderNo) .
' AND trash = 0',
'*', 0);
3096 if (!is_array($row) || (
int)($row[
'id'] ?? 0) <= 0)
return null;
3097 $row[
'items'] = $this->
orderItems((
int)$row[
'id']);
3098 $row[
'history'] = $this->
orderHistory((
int)$row[
'id']);
3105 $row = $this->db()->select1($this->dd(
'shopOrder'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
3106 if (!is_array($row) || (
int)($row[
'id'] ?? 0) <= 0)
return null;
3107 $row[
'items'] = $this->
orderItems((
int)$row[
'id']);
3108 $row[
'history'] = $this->
orderHistory((
int)$row[
'id']);
3118 $rows = $this->db()->select($this->dd(
'shopOrder'),
'uid = ' . (
int)$uid .
' AND trash = 0',
'*',
'create_date DESC, id DESC',
'ASC',
'', max(1, min(100, $limit)), 0, 0);
3120 foreach (
$rows as &$row) {
3121 $orderId = (int)($row[
'id'] ?? 0);
3122 $row[
'items'] = $this->orderItems($orderId);
3123 $row[
'history'] = $this->orderHistory($orderId);
3124 $row[
'withdrawals'] = $this->withdrawalsForOrder($orderId);
3130 public function orders(array $filters = array(),
int $limit = 50,
int $offset = 0,
string $sort =
'create_date',
string $direction =
'DESC'): array {
3132 $where = array(
'trash = 0');
3134 $query = trim((
string)($filters[
'query'] ??
''));
3135 if ($query !==
'') {
3136 $like = $this->sqlLikeValue($query);
3137 $where[] =
'(order_no LIKE ' . $like .
' OR customer_name LIKE ' . $like .
' OR customer_email LIKE ' . $like .
' OR channel_key LIKE ' . $like .
' OR payment_reference LIKE ' . $like .
')';
3139 foreach (array(
'status',
'payment_status',
'shipping_status',
'channel_key') as
$field) {
3146 $allowedSort = array(
'create_date',
'order_no',
'status',
'payment_status',
'shipping_status',
'customer_name',
'total_gross',
'channel_key');
3147 if (!in_array($sort, $allowedSort,
true)) {
3148 $sort =
'create_date';
3150 $direction = strtoupper($direction) ===
'ASC' ?
'ASC' :
'DESC';
3151 $max = $limit > 0 ? max(1, $limit) : 0;
3152 $rows = $this->db()->select($this->dd(
'shopOrder'), implode(
' AND ', $where),
'*', $sort .
' ' . $direction .
', id DESC',
'ASC',
'', $max, max(0, $offset), 0);
3154 foreach (
$rows as &$row) {
3155 $row[
'items'] = $this->orderItems((
int)$row[
'id']);
3163 $where = array(
'trash = 0');
3164 $query = trim((
string)($filters[
'query'] ??
''));
3165 if ($query !==
'') {
3166 $like = $this->sqlLikeValue($query);
3167 $where[] =
'(order_no LIKE ' . $like .
' OR customer_name LIKE ' . $like .
' OR customer_email LIKE ' . $like .
' OR channel_key LIKE ' . $like .
' OR payment_reference LIKE ' . $like .
')';
3169 foreach (array(
'status',
'payment_status',
'shipping_status',
'channel_key') as
$field) {
3175 return max(0, (
int)$this->db()->count($this->dd(
'shopOrder'), implode(
' AND ', $where)));
3180 $rows = $this->db()->select($this->dd(
'shopOrder'),
"trash = 0 AND channel_key <> ''",
'channel_key',
'channel_key ASC',
'ASC',
'', 0, 0, 0);
3182 foreach (is_array(
$rows) ?
$rows : array() as $row) {
3183 $key = (string)($row[
'channel_key'] ??
'');
3189 return array_values(
$keys);
3194 $allowedStatus = array(
'new',
'payment_pending',
'paid',
'processing',
'shipped',
'done',
'cancelled');
3195 $allowedPayment = array(
'open',
'created',
'pending',
'completed',
'paid',
'failed',
'cancelled',
'refunded');
3196 $allowedShipping = array(
'open',
'ready',
'shipped',
'delivered',
'returned');
3198 $status = (string)($data[
'status'] ??
'new');
3199 $paymentStatus = (string)($data[
'payment_status'] ??
'open');
3200 $shippingStatus = (string)($data[
'shipping_status'] ?? ($before[
'shipping_status'] ??
'open'));
3201 if (!in_array($status, $allowedStatus,
true)) $status =
'new';
3202 if (!in_array($paymentStatus, $allowedPayment,
true)) $paymentStatus =
'open';
3203 if (!in_array($shippingStatus, $allowedShipping,
true)) $shippingStatus =
'open';
3204 $invoiceNo = trim((
string)($data[
'invoice_no'] ?? ($before[
'invoice_no'] ??
'')));
3205 $invoiceDate = trim((
string)($data[
'invoice_date'] ?? ($before[
'invoice_date'] ??
'')));
3206 if ($invoiceNo ===
'' && in_array($status, array(
'paid',
'processing',
'shipped',
'done'),
true)) {
3208 $invoiceDate = date(
'Y-m-d');
3210 $shippedDate = trim((
string)($data[
'shipped_date'] ?? ($before[
'shipped_date'] ??
'')));
3211 if ($shippingStatus ===
'shipped' && $shippedDate ===
'') {
3212 $shippedDate = date(
'Y-m-d H:i:s');
3214 $shippingProvider = trim((
string)($data[
'shipping_provider'] ??
''));
3215 $trackingNo = trim((
string)($data[
'tracking_no'] ??
''));
3216 $trackingUrl = trim((
string)($data[
'tracking_url'] ??
''));
3217 if ($trackingUrl ===
'' && $trackingNo !==
'') {
3218 $trackingUrl = $this->trackingUrlForProvider($shippingProvider, $trackingNo);
3221 $ok = $this->db()->update($this->dd(
'shopOrder'), array(
3222 'update_date' => date(
'Y-m-d H:i:s'),
3223 'status' => $status,
3224 'payment_status' => $paymentStatus,
3225 'payment_reference' => (
string)($data[
'payment_reference'] ??
''),
3226 'invoice_no' => $invoiceNo,
3227 'invoice_date' => $invoiceDate,
3228 'shipping_status' => $shippingStatus,
3229 'shipping_provider' => $shippingProvider,
3230 'tracking_no' => $trackingNo,
3231 'tracking_url' => $trackingUrl,
3232 'shipped_date' => $shippedDate,
3233 'note' => (
string)($data[
'note'] ??
''),
3234 ),
'id = ' . (
int)$id .
' AND trash = 0', 0);
3235 if (is_array($before)) {
3237 'status' => $status,
3238 'payment_status' => $paymentStatus,
3239 'shipping_status' => $shippingStatus,
3240 'invoice_no' => $invoiceNo,
3241 'tracking_no' => $trackingNo,
3242 ) as
$field => $newValue) {
3243 $oldValue = (string)($before[
$field] ??
'');
3244 if ($oldValue !== (
string)$newValue) {
3245 $this->addOrderHistory($id,
$field, $oldValue, (
string)$newValue,
'Admin-Aenderung');
3248 if ($status ===
'cancelled' || in_array($paymentStatus, array(
'cancelled',
'refunded'),
true) || $shippingStatus ===
'returned') {
3249 $fresh = $this->orderById($id);
3250 if (is_array($fresh)) {
3251 $this->releaseStockForOrder($fresh,
'Bestand wurde durch Statusaenderung zurueckgebucht.');
3255 return $ok !== 0 || $this->orderById($id) !==
null;
3258 private function trackingUrlForProvider(
string $provider,
string $trackingNo): string {
3259 $trackingNo = trim($trackingNo);
3260 if ($trackingNo ===
'') {
3263 $providerKey = strtolower(trim($provider));
3264 if (str_contains($providerKey,
'dhl')) {
3265 return 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=' . rawurlencode($trackingNo);
3267 if (str_contains($providerKey,
'ups')) {
3268 return 'https://www.ups.com/track?tracknum=' . rawurlencode($trackingNo);
3270 if (str_contains($providerKey,
'dpd')) {
3271 return 'https://tracking.dpd.de/status/de_DE/parcel/' . rawurlencode($trackingNo);
3273 if (str_contains($providerKey,
'hermes')) {
3274 return 'https://www.myhermes.de/empfangen/sendungsverfolgung/?su=' . rawurlencode($trackingNo);
3283 return array(
false,
'Bestellung nicht gefunden.');
3290 $data[
'payment_status'] =
'paid';
3291 if (in_array((
string)($data[
'status'] ??
''), array(
'new',
'payment_pending'),
true)) {
3292 $data[
'status'] =
'paid';
3294 $message =
'Bestellung wurde als bezahlt markiert.';
3298 $data[
'status'] =
'processing';
3299 $message =
'Bestellung wurde in Bearbeitung gesetzt.';
3303 $data[
'shipping_status'] =
'ready';
3304 if (in_array((
string)($data[
'status'] ??
''), array(
'new',
'payment_pending',
'paid'),
true)) {
3305 $data[
'status'] =
'processing';
3307 $message =
'Bestellung wurde als versandbereit markiert.';
3311 $data[
'shipping_status'] =
'shipped';
3312 $data[
'status'] =
'shipped';
3313 if (trim((
string)($data[
'shipped_date'] ??
'')) ===
'') {
3314 $data[
'shipped_date'] = date(
'Y-m-d H:i:s');
3316 $message =
'Bestellung wurde als versendet markiert.';
3320 $data[
'shipping_status'] =
'delivered';
3321 $data[
'status'] =
'done';
3322 $message =
'Bestellung wurde als zugestellt und abgeschlossen markiert.';
3326 $data[
'status'] =
'cancelled';
3327 if (!in_array((
string)($data[
'payment_status'] ??
''), array(
'completed',
'paid',
'refunded'),
true)) {
3328 $data[
'payment_status'] =
'cancelled';
3330 $message =
'Bestellung wurde storniert.';
3334 $data[
'payment_status'] =
'refunded';
3335 $data[
'status'] =
'cancelled';
3336 if ((
string)($data[
'shipping_status'] ??
'') !==
'delivered') {
3337 $data[
'shipping_status'] =
'returned';
3339 $message =
'Bestellung wurde als erstattet markiert.';
3343 return array(
false,
'Unbekannte Bestellaktion.');
3346 $ok = $this->updateOrderAdmin($id, $data);
3348 $this->addOrderHistory($id,
'quick_action',
'', $action, $message);
3349 return array(
true, $message);
3351 return array(
false,
'Bestellaktion konnte nicht gespeichert werden.');
3356 return $this->db()->update($this->dd(
'shopOrder'), array(
'trash' => 1,
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$id .
' AND trash = 0', 0) !== 0;
3361 $row = $this->db()->select1($this->dd(
'shopOrder'),
'payment_provider = ' . $this->sqlValue($provider) .
' AND payment_reference = ' . $this->sqlValue(
$reference) .
' AND trash = 0',
'*', 0);
3362 if (!is_array($row) || (
int)($row[
'id'] ?? 0) <= 0)
return null;
3363 $row[
'items'] = $this->
orderItems((
int)$row[
'id']);
3368 $rows = $this->db()->select($this->dd(
'shopOrderItem'),
'order_id = ' . (int)$orderId .
' AND trash = 0',
'*',
'id ASC',
'ASC',
'', 0, 0, 0);
3372 private function paymentProcessingRetrySeconds(): int {
3373 $seconds = (int)
dbx()->get_config(
'dbxShop',
'payment_processing_retry_seconds', 300);
3374 return max(60, min(3600, $seconds));
3381 private function isStalePaymentProcessing(array
$order,
int $retrySeconds, ?
int $now =
null): bool {
3382 if (strtolower(trim((string)(
$order[
'payment_status'] ??
''))) !==
'processing') {
3385 $updatedAt = strtotime((
string)(
$order[
'update_date'] ??
''));
3386 if ($updatedAt ===
false || $updatedAt <= 0) {
3390 return $updatedAt <=
$now - max(60, $retrySeconds);
3402 $provider = trim($provider);
3404 if ($orderId <= 0 || $provider ===
'' ||
$reference ===
'')
return false;
3407 if (
$db->begin($this->dd(
'shopOrder')) !== 1)
return false;
3409 $order =
$db->select1($this->dd(
'shopOrder'),
'id = ' . $orderId .
' AND trash = 0',
'*', 0);
3410 $oldStatus = strtolower(trim((
string)(
$order[
'payment_status'] ??
'')));
3411 $staleProcessing = is_array(
$order)
3412 && $this->isStalePaymentProcessing(
$order, $this->paymentProcessingRetrySeconds());
3414 || !hash_equals($provider, (
string)(
$order[
'payment_provider'] ??
''))
3416 || (!in_array($oldStatus, array(
'open',
'created',
'failed'),
true) && !$staleProcessing)) {
3417 $db->rollback($this->dd(
'shopOrder'));
3421 $where =
'id = ' . $orderId .
' AND trash = 0'
3422 .
' AND payment_provider = ' . $this->sqlValue($provider)
3423 .
' AND payment_reference = ' . $this->sqlValue(
$reference)
3424 .
' AND payment_status = ' . $this->sqlValue($oldStatus);
3425 if ($staleProcessing) {
3428 $where .=
' AND update_date = ' . $this->sqlValue((
string)(
$order[
'update_date'] ??
''));
3430 if (
$db->update($this->dd(
'shopOrder'), array(
3431 'payment_status' =>
'processing',
3432 'status' =>
'payment_pending',
3433 'update_date' => date(
'Y-m-d H:i:s'),
3434 ), $where, 0) !== 1 || (
int)
$db->_update_count !== 1) {
3435 $db->rollback($this->dd(
'shopOrder'));
3438 $claimMessage = $staleProcessing
3439 ?
'Verwaister Zahlungsabschluss wurde mit demselben Idempotency-Key erneut beansprucht.'
3440 :
'Zahlungsabschluss wurde atomar beansprucht.';
3441 if (!$this->addOrderHistory($orderId,
'payment', $oldStatus,
'processing', $claimMessage)
3442 ||
$db->commit($this->dd(
'shopOrder')) !== 1) {
3443 throw new \RuntimeException(
'payment_claim_commit_failed');
3446 }
catch (\Throwable $e) {
3447 $db->rollback($this->dd(
'shopOrder'));
3448 dbx()->debug(
'#Shop payment claim rollback order=(' . $orderId .
') error=(' . $e->getMessage() .
')');
3459 $provider = trim($provider);
3460 $status = strtolower(trim($status));
3462 $allowed = array(
'open',
'created',
'processing',
'pending',
'completed',
'paid',
'failed',
'cancelled',
'refunded');
3463 if ($orderId <= 0 || $provider ===
'' || !in_array($status,
$allowed,
true))
return false;
3466 if (
$db->begin($this->dd(
'shopOrder')) !== 1)
return false;
3468 $order =
$db->select1($this->dd(
'shopOrder'),
'id = ' . $orderId .
' AND trash = 0',
'*', 0);
3470 $db->rollback($this->dd(
'shopOrder'));
3473 $oldProvider = trim((
string)(
$order[
'payment_provider'] ??
''));
3474 $oldReference = trim((
string)(
$order[
'payment_reference'] ??
''));
3475 $oldStatus = strtolower((
string)(
$order[
'payment_status'] ??
'open'));
3476 if (($oldProvider !==
'' && !hash_equals($oldProvider, $provider))
3478 $db->rollback($this->dd(
'shopOrder'));
3483 if ((in_array($oldStatus, array(
'completed',
'paid'),
true)
3484 && !in_array($status, array(
'completed',
'paid',
'refunded'),
true))
3485 || ($oldStatus ===
'refunded' && $status !==
'refunded')
3486 || ($oldStatus ===
'cancelled' && !in_array($status, array(
'cancelled',
'refunded'),
true))) {
3487 $db->rollback($this->dd(
'shopOrder'));
3491 if ($oldStatus === $status && $oldProvider === $provider && $oldReference ===
$reference) {
3492 $db->rollback($this->dd(
'shopOrder'));
3496 $orderStatus = in_array($status, array(
'completed',
'paid'),
true)
3498 : (in_array($status, array(
'cancelled',
'refunded'),
true) ?
'cancelled' :
'payment_pending');
3499 $where =
'id = ' . $orderId .
' AND trash = 0 AND payment_status = ' . $this->sqlValue($oldStatus);
3500 if (
$db->update($this->dd(
'shopOrder'), array(
3501 'update_date' => date(
'Y-m-d H:i:s'),
3502 'payment_provider' => $provider,
3503 'payment_status' => $status,
3505 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
3506 'status' => $orderStatus,
3507 ), $where, 0) !== 1 || (
int)
$db->_update_count !== 1) {
3508 $db->rollback($this->dd(
'shopOrder'));
3511 if (!$this->addOrderHistory($orderId,
'payment', $oldStatus, $status,
'Zahlungsstatus wurde aktualisiert.')
3512 ||
$db->commit($this->dd(
'shopOrder')) !== 1) {
3513 throw new \RuntimeException(
'payment_update_commit_failed');
3515 }
catch (\Throwable $e) {
3516 $db->rollback($this->dd(
'shopOrder'));
3517 dbx()->debug(
'#Shop payment rollback order=(' . $orderId .
') error=(' . $e->getMessage() .
')');
3521 if (in_array($status, array(
'cancelled',
'refunded'),
true)) {
3522 $fresh = $this->orderById($orderId);
3523 if (is_array($fresh)) {
3524 $this->releaseStockForOrder($fresh,
'Bestand wurde durch Zahlungsstatus zurueckgebucht.');
3530 public function addOrderHistory(
int $orderId,
string $eventType,
string $oldValue =
'',
string $newValue =
'',
string $message =
''): bool {
3531 if ($orderId <= 0) {
3534 $order = $this->db()->select1($this->dd(
'shopOrder'),
'id = ' . (
int)$orderId .
' AND trash = 0',
'owner,uid', 0);
3540 'order_id' => $orderId,
3541 'event_type' => $eventType,
3542 'old_value' => $oldValue,
3543 'new_value' => $newValue,
3544 'message' => $message,
3549 return $this->db()->insert($this->dd(
'shopOrderHistory'), $data, 0) === 1;
3553 $rows = $this->db()->select($this->dd(
'shopOrderHistory'),
'order_id = ' . (int)$orderId .
' AND trash = 0',
'*',
'create_date DESC, id DESC',
'ASC',
'', 0, 0, 0);
3558 if ($orderId <= 0 || trim($eventType) ===
'') return false;
3559 $where =
'order_id = ' . $orderId
3560 .
' AND event_type = ' . $this->sqlValue(trim($eventType))
3562 if ($newValue !==
'') {
3563 $where .=
' AND new_value = ' . $this->sqlValue($newValue);
3565 return $this->db()->count($this->dd(
'shopOrderHistory'), $where) > 0;
3569 $prefix =
'R' . date(
'Y');
3570 $rows = $this->db()->select($this->dd(
'shopOrder'),
'invoice_no LIKE ' . $this->sqlValue($prefix .
'%'),
'invoice_no',
'invoice_no DESC',
'DESC',
'', 1, 0, 0);
3571 $last = is_array(
$rows) && isset(
$rows[0][
'invoice_no']) ? (string)
$rows[0][
'invoice_no'] :
'';
3573 if (preg_match(
'/(\d+)$/', $last, $m)) {
3574 $next = ((int)$m[1]) + 1;
3576 return $prefix .
'-' . str_pad((
string)$next, 5,
'0', STR_PAD_LEFT);
3579 private function invoiceArchiveDir(
string $year): string {
3581 return $base .
'/files/shop/invoices/' . $year;
3584 private function invoiceArchiveRelPath(
string $year,
string $fileName): string {
3585 return
'files/shop/invoices/' . $year .
'/' . $fileName;
3588 private function pdfText(
string $text): string {
3589 $text = str_replace(array(
"\r\n",
"\r"),
"\n", $text);
3590 $text = strtr($text, array(
3598 $converted = function_exists(
'iconv') ? @iconv(
'UTF-8',
'Windows-1252//TRANSLIT', $text) : false;
3599 if ($converted !==
false) {
3602 return str_replace(array(
'\\',
'(',
')'), array(
'\\\\',
'\\(',
'\\)'), $text);
3605 private function createSimpleInvoicePdf(array
$order,
string $absFile): bool {
3606 $invoiceNo = trim((string)(
$order[
'invoice_no'] ??
''));
3607 $invoiceDate = trim((
string)(
$order[
'invoice_date'] ?? date(
'Y-m-d')));
3609 'Rechnung ' . $invoiceNo,
3610 'Datum: ' . $invoiceDate,
3611 'Bestellung: ' . (
string)(
$order[
'order_no'] ??
''),
3614 (
string)(
$order[
'customer_name'] ??
''),
3615 (
string)(
$order[
'customer_email'] ??
''),
3617 $address = trim((
string)(
$order[
'shipping_address'] ??
''));
3618 if ($address !==
'') {
3620 $lines[] =
'Lieferadresse:';
3621 foreach (explode(
"\n", str_replace(
"\r",
'', $address)) as $line) {
3626 $lines[] =
'Positionen:';
3627 foreach ((array)(
$order[
'items'] ?? array()) as $item) {
3628 $title = trim((
string)($item[
'title'] ??
'Artikel'));
3629 $sku = trim((
string)($item[
'sku'] ??
''));
3630 $qty = (int)($item[
'qty'] ?? 0);
3631 $total = number_format((
float)($item[
'total_gross'] ?? 0), 2,
',',
'.') .
' EUR';
3632 $tax = number_format((
float)($item[
'tax_rate'] ?? 0), 2,
',',
'.') .
' % MwSt.';
3633 $lines[] = $qty .
' x ' . $title . ($sku !==
'' ?
' [' . $sku .
']' :
'') .
' - ' . $total .
' (' . $tax .
')';
3636 $lines[] =
'Gesamtbetrag: ' . number_format((
float)(
$order[
'total_gross'] ?? 0), 2,
',',
'.') .
' EUR';
3638 $lines[] =
'Dieser Beleg wurde aus dem gespeicherten Bestell-Snapshot erzeugt.';
3640 $content =
"BT\n/F1 18 Tf\n50 790 Td\n(" . $this->pdfText(array_shift($lines) ?:
'Rechnung') .
") Tj\n/F1 10 Tf\n0 -24 Td\n";
3641 foreach ($lines as $line) {
3642 foreach (explode(
"\n", wordwrap((
string)$line, 95,
"\n",
true)) as $wrapped) {
3643 $content .=
'(' . $this->pdfText($wrapped) .
") Tj\n0 -14 Td\n";
3649 $objects[] =
"<< /Type /Catalog /Pages 2 0 R >>";
3650 $objects[] =
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
3651 $objects[] =
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>";
3652 $objects[] =
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
3653 $objects[] =
"<< /Length " . strlen(
$content) .
" >>\nstream\n" .
$content .
"endstream";
3655 $pdf =
"%PDF-1.4\n";
3656 $offsets = array(0);
3657 foreach ($objects as $i => $object) {
3658 $offsets[$i + 1] = strlen($pdf);
3659 $pdf .= ($i + 1) .
" 0 obj\n" . $object .
"\nendobj\n";
3661 $xref = strlen($pdf);
3662 $pdf .=
"xref\n0 " . (count($objects) + 1) .
"\n";
3663 $pdf .=
"0000000000 65535 f \n";
3664 for ($i = 1; $i <= count($objects); $i++) {
3665 $pdf .= str_pad((
string)$offsets[$i], 10,
'0', STR_PAD_LEFT) .
" 00000 n \n";
3667 $pdf .=
"trailer\n<< /Size " . (count($objects) + 1) .
" /Root 1 0 R >>\nstartxref\n" . $xref .
"\n%%EOF";
3669 $dir = dirname($absFile);
3670 if (!is_dir(
$dir) && !@mkdir(
$dir, 0775,
true)) {
3673 return @file_put_contents($absFile, $pdf) !==
false;
3683 $invoiceNo = trim((
string)(
$order[
'invoice_no'] ??
''));
3684 $invoiceDate = trim((
string)(
$order[
'invoice_date'] ??
''));
3686 if ($invoiceNo ===
'') {
3687 $invoiceNo = $this->nextInvoiceNo();
3688 $updates[
'invoice_no'] = $invoiceNo;
3690 if ($invoiceDate ===
'') {
3691 $invoiceDate = date(
'Y-m-d');
3692 $updates[
'invoice_date'] = $invoiceDate;
3694 if ($updates !== array()) {
3695 $updates[
'update_date'] = date(
'Y-m-d H:i:s');
3696 $this->db()->update($this->dd(
'shopOrder'), $updates,
'id = ' . (
int)$id .
' AND trash = 0', 0);
3700 $year = substr($invoiceDate, 0, 4);
3701 if (!preg_match(
'/^\d{4}$/', $year)) {
3704 $safeNo = preg_replace(
'/[^A-Za-z0-9_.-]+/',
'-', $invoiceNo) ?: (
'rechnung-' . $id);
3705 $fileName = $safeNo .
'.pdf';
3706 $relPath = $this->invoiceArchiveRelPath($year, $fileName);
3707 $absPath = $this->invoiceArchiveDir($year) .
'/' . $fileName;
3708 $oldRelPath = trim((
string)(
$order[
'invoice_pdf_path'] ??
''));
3711 if (!is_file($absPath)) {
3712 if (!$this->createSimpleInvoicePdf(
$order, $absPath)) {
3718 $this->db()->update($this->dd(
'shopOrder'), array(
3719 'invoice_pdf_path' => $relPath,
3720 'invoice_pdf_date' => date(
'Y-m-d H:i:s'),
3721 'update_date' => date(
'Y-m-d H:i:s'),
3722 ),
'id = ' . (
int)$id .
' AND trash = 0', 0);
3723 if (
$created || $oldRelPath !== $relPath) {
3724 $this->addOrderHistory($id,
'invoice_pdf',
'', $relPath,
'Rechnungs-PDF wurde erzeugt oder aktualisiert.');
3726 return $this->orderById($id);
3730 $rel = trim((string)(
$order[
'invoice_pdf_path'] ??
''));
3735 $path =
$base .
'/' . ltrim($rel,
'/\\');
3736 return is_file($path) ? $path :
'';
3741 $orderNo = trim((
string)($data[
'order_no'] ??
''));
3743 $orderId = is_array(
$order) ? (int)(
$order[
'id'] ?? 0) : 0;
3744 $ok = (int)$this->db()->insert($this->dd(
'shopWithdrawal'), array(
3745 'order_id' => $orderId,
3746 'order_no' => $orderNo,
3747 'customer_name' => trim((
string)($data[
'customer_name'] ??
'')),
3748 'customer_email' => trim((
string)($data[
'customer_email'] ??
'')),
3749 'customer_address' => trim((
string)($data[
'customer_address'] ??
'')),
3750 'reason' => trim((
string)($data[
'reason'] ??
'')),
3756 $id = (int)$this->db()->get_insert_id();
3758 $this->addOrderHistory($orderId,
'withdrawal',
'',
'new',
'Widerruf wurde eingereicht.');
3760 return $this->withdrawalById($id);
3765 $allowed = array(
'new',
'processing',
'accepted',
'rejected',
'refunded',
'closed');
3766 if (!in_array($status,
$allowed,
true)) {
3769 $before = $this->withdrawalById($id);
3770 if (!is_array($before)) {
3774 $ok = $this->db()->update($this->dd(
'shopWithdrawal'), array(
3775 'status' => $status,
3776 'admin_note' => $note !==
'' ? $note : (
string)($before[
'admin_note'] ??
''),
3777 'update_date' => date(
'Y-m-d H:i:s'),
3778 ),
'id = ' . (
int)$id .
' AND trash = 0', 0);
3780 $orderId = (int)($before[
'order_id'] ?? 0);
3782 $old = (string)($before[
'status'] ??
'');
3783 if ($old !== $status) {
3784 $this->addOrderHistory($orderId,
'withdrawal_status', $old, $status,
'Widerrufsstatus wurde geaendert.');
3786 if (in_array($status, array(
'accepted',
'refunded'),
true)) {
3787 $order = $this->orderById($orderId);
3789 $this->releaseStockForOrder(
$order,
'Bestand wurde durch Widerruf zurueckgebucht.');
3794 return $ok !== 0 || $this->withdrawalById($id) !==
null;
3798 $row = $this->db()->select1($this->dd(
'shopWithdrawal'),
'id = ' . (int)$id .
' AND trash = 0',
'*', 0);
3799 return is_array($row) ? $row :
null;
3802 public function withdrawals(array $filters = array(),
int $limit = 50,
int $offset = 0): array {
3804 $where = array(
'trash = 0');
3805 $query = trim((
string)($filters[
'query'] ??
''));
3806 if ($query !==
'') {
3807 $like = $this->sqlLikeValue($query);
3808 $where[] =
'(order_no LIKE ' . $like .
' OR customer_name LIKE ' . $like .
' OR customer_email LIKE ' . $like .
')';
3810 $status = trim((
string)($filters[
'status'] ??
''));
3811 if ($status !==
'') {
3812 $where[] =
'status = ' . $this->sqlValue($status);
3814 $rows = $this->db()->select($this->dd(
'shopWithdrawal'), implode(
' AND ', $where),
'*',
'create_date DESC, id DESC',
'ASC',
'', $limit > 0 ? $limit : 0, max(0, $offset), 0);
3819 $where = array(
'trash = 0');
3820 $query = trim((
string)($filters[
'query'] ??
''));
3821 if ($query !==
'') {
3822 $like = $this->sqlLikeValue($query);
3823 $where[] =
'(order_no LIKE ' . $like .
' OR customer_name LIKE ' . $like .
' OR customer_email LIKE ' . $like .
')';
3825 $status = trim((
string)($filters[
'status'] ??
''));
3826 if ($status !==
'') {
3827 $where[] =
'status = ' . $this->sqlValue($status);
3829 return max(0, (
int)$this->db()->count($this->dd(
'shopWithdrawal'), implode(
' AND ', $where)));
3833 if ($orderId <= 0) {
3836 $rows = $this->db()->select($this->dd(
'shopWithdrawal'),
'order_id = ' . (
int)$orderId .
' AND trash = 0',
'*',
'create_date DESC, id DESC',
'ASC',
'', 0, 0, 0);
3842 $ordersOpen = (int)$this->db()->count($this->dd(
'shopOrder'),
"trash = 0 AND status IN ('new','payment_pending','paid','processing')");
3843 $paymentsOpen = (int)$this->db()->count($this->dd(
'shopOrder'),
"trash = 0 AND payment_status IN ('open','created','pending')");
3844 $shipOpen = (int)$this->db()->count($this->dd(
'shopOrder'),
"trash = 0 AND shipping_status IN ('open','ready')");
3845 $withdrawalsOpen = (int)$this->db()->count($this->dd(
'shopWithdrawal'),
"trash = 0 AND status IN ('new','processing')");
3847 if ($this->stockEnabled()) {
3848 $stockLow = (int)$this->db()->count($this->dd(
'shopProduct'),
"trash = 0 AND active = 1 AND product_type = 'physical' AND stock <= 3");
3851 'orders_open' => $ordersOpen,
3852 'payments_open' => $paymentsOpen,
3853 'shipping_open' => $shipOpen,
3854 'withdrawals_open' => $withdrawalsOpen,
3855 'stock_low' => $stockLow,
3856 'products_active' => (
int)$this->db()->count($this->dd(
'shopProduct'),
'trash = 0 AND active = 1'),