5 private function db() {
6 return dbx()->get_system_obj(
'dbxDB');
9 private function dd(
string $name):
string {
10 return 'dbxShop|' . $name;
13 private function sqlValue($value):
string {
14 if ($value ===
null) {
17 return "'" . str_replace(
"'",
"''", (
string)$value) .
"'";
20 private function sqlLikeValue(
string $value):
string {
21 return $this->sqlValue(
'%' . $value .
'%');
24 private function syncDdToDb(
string $dd):
bool {
25 $oDD =
dbx()->get_system_obj(
'dbxDD');
26 $oDD->sync_dd_to_db(
'dbxShop', $dd,
'reset');
27 for ($i = 0; $i < 40; $i++) {
28 $state = $oDD->sync_dd_to_db(
'dbxShop', $dd,
'apply');
29 $status = (string)($state[
'status'] ??
'');
30 if ($status ===
'finished') {
33 if (in_array($status, array(
'error',
'cancelled'),
true)) {
40 private function syncShopSchemaFromDd():
void {
46 $version =
'shop-dd-20260713-2';
47 if ((
string)
dbx()->get_config(
'dbxShop',
'schema_sync_version',
'') === $version) {
57 'shopAttributeDefinition',
58 'shopProductAttributeValue',
59 'shopProductGroupMap',
62 'shopProductShippingGroupMap',
64 'shopChannelGroupChannel',
65 'shopProductChannelGroupMap',
71 if (!$this->syncDdToDb($dd)) {
72 throw new \RuntimeException(
'dbxShop-DD konnte nicht mit der Datenbank synchronisiert werden: ' . $dd);
76 $cfg =
dbx()->get_config(
'dbxShop',
'', array());
78 $cfg[
'schema_sync_version'] = $version;
79 dbx()->set_config(
'dbxShop',
$cfg);
84 static $installed = false;
88 $this->syncShopSchemaFromDd();
89 $this->syncChannelDefaults();
90 $this->syncPrimaryProductGroups();
91 $this->syncSingleGroupImages();
96 private function syncPrimaryProductGroups(): void {
97 $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);
98 foreach ((is_array($rows) ? $rows : array()) as $row) {
99 $productId = (int)($row[
'id'] ?? 0);
100 if ($productId <= 0)
continue;
101 $maps = $this->db()->select($this->dd(
'shopProductGroupMap'),
'product_id = ' . $productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 1, 0);
102 $map = is_array($maps) && isset($maps[0]) ? $maps[0] : array();
103 $groupId = (int)($map[
'group_id'] ?? 0);
105 $this->db()->update($this->dd(
'shopProduct'), array(
'product_group_id' => $groupId),
'id = ' . $productId, 0);
110 private function syncSingleGroupImages(): void {
111 $groups = $this->db()->select($this->dd(
'shopProductGroup'),
'trash = 0',
'id',
'',
'ASC',
'', 0, 0, 0);
112 foreach ((is_array($groups) ? $groups : array()) as $group) {
113 $groupId = (int)($group[
'id'] ?? 0);
114 if ($groupId <= 0)
continue;
115 $rows = $this->db()->select(
116 $this->dd(
'shopProductImage'),
117 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . $groupId,
119 'is_primary DESC, sorter ASC, title ASC',
127 foreach ((is_array($rows) ? $rows : array()) as $row) {
128 $id = (int)($row[
'id'] ?? 0);
129 if ($id <= 0)
continue;
132 $this->db()->update($this->dd(
'shopProductImage'), array(
'is_primary' => 1,
'sorter' => 10),
'id = ' . $id, 0);
135 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1),
'id = ' . $id, 0);
140 private function syncChannelDefaults(): void {
142 array(
'shop',
'Shop',
'Eigener Shop ohne externe API.',
'shop',
'internal', 1, 0,
'',
'',
'',
'', 10),
143 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),
144 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),
145 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),
146 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),
148 foreach ($channels as $c) {
149 $existing = $this->db()->select1($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($c[0]),
'*', 0);
150 $existing = is_array($existing) ? $existing : array();
152 'channel_key' => $c[0],
153 'title' => trim((
string)($existing[
'title'] ??
'')) !==
'' ? (
string)$existing[
'title'] : $c[1],
154 'description' => trim((
string)($existing[
'description'] ??
'')) !==
'' ? (
string)$existing[
'description'] : $c[2],
155 'platform_type' => trim((
string)($existing[
'platform_type'] ??
'')) !==
'' && (
string)($existing[
'platform_type'] ??
'') !==
'custom' ? (
string)$existing[
'platform_type'] : $c[3],
156 'connection_mode' => trim((
string)($existing[
'connection_mode'] ??
'')) !==
'' && (
string)($existing[
'connection_mode'] ??
'') !==
'manual' ? (
string)$existing[
'connection_mode'] : $c[4],
157 'export_enabled' => array_key_exists(
'export_enabled', $existing) ? (
int)$existing[
'export_enabled'] : $c[5],
158 'order_import_enabled' => array_key_exists(
'order_import_enabled', $existing) ? (
int)$existing[
'order_import_enabled'] : $c[6],
159 'api_base_url' => trim((
string)($existing[
'api_base_url'] ??
'')) !==
'' ? (
string)$existing[
'api_base_url'] : $c[7],
160 'marketplace_id' => trim((
string)($existing[
'marketplace_id'] ??
'')) !==
'' ? (
string)$existing[
'marketplace_id'] : $c[8],
161 'notification_topic' => trim((
string)($existing[
'notification_topic'] ??
'')) !==
'' ? (
string)$existing[
'notification_topic'] : $c[9],
162 'api_scope' => trim((
string)($existing[
'api_scope'] ??
'')) !==
'' ? (
string)$existing[
'api_scope'] : $c[10],
163 'active' => array_key_exists(
'active', $existing) ? (
int)$existing[
'active'] : 1,
164 'sorter' => array_key_exists(
'sorter', $existing) ? (
int)$existing[
'sorter'] : $c[11],
166 $this->db()->save($this->dd(
'shopChannel'), $values,
'channel_key = ' . $this->sqlValue($c[0]), 0);
168 $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';
169 $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";
171 $this->dd(
'shopChannel'),
172 array(
'api_scope' => $newEbayScopes),
173 'channel_key = ' . $this->sqlValue(
'ebay') .
' AND api_scope = ' . $this->sqlValue($oldEbayScopes),
178 private function seedDemoProductsWithDbxDb(): void {
179 if ($this->db()->count($this->dd(
'shopProduct'),
'trash = 0') > 0) {
183 $this->updateProductGroup(0, array(
184 'group_key' =>
'software',
185 'title' =>
'Software',
186 'description' =>
'Digitale dbXapp Pakete und Erweiterungen.',
187 'tax_class' =>
'mwst1',
188 'display_variant' =>
'gallery_grid',
189 'card_template' =>
'product-card-default',
190 'detail_template' =>
'product-detail-default',
191 'gallery_template' =>
'image-gallery',
192 'gallery_visible_count' => 3,
193 'gallery_image_size' =>
'original',
194 'gallery_lightbox_width' =>
'100vw',
195 'gallery_overflow' =>
'grid',
196 'gallery_click' =>
'lightbox',
197 'attribute_notes' =>
'Lizenz, Paketumfang, CMS-Funktionen und KI-Funktionen.',
201 $this->updateProductGroup(0, array(
202 'group_key' =>
'service',
203 'title' =>
'Dienstleistungen',
204 'description' =>
'Installation, Wartung und Schulung.',
205 'tax_class' =>
'mwst1',
206 'display_variant' =>
'gallery_slider',
207 'card_template' =>
'product-card-default',
208 'detail_template' =>
'product-detail-default',
209 'gallery_template' =>
'image-gallery',
210 'gallery_visible_count' => 1,
211 'gallery_image_size' =>
'original',
212 'gallery_lightbox_width' =>
'100vw',
213 'gallery_overflow' =>
'slider',
214 'gallery_click' =>
'lightbox',
215 'attribute_notes' =>
'Leistungsumfang, Dauer, Termin und Uebergabe.',
220 $this->updateShippingGroup(0, array(
221 'group_key' =>
'digital-free',
222 'title' =>
'Digital / kein Versand',
223 'description' =>
'Digitale Lieferung ohne Versandkosten.',
224 'shipping_way' =>
'Download / Freischaltung',
225 'delivery_time' =>
'Sofort nach Freischaltung',
226 'shipping_gross' => 0,
227 'free_from_gross' => -1,
231 $this->updateShippingGroup(0, array(
232 'group_key' =>
'service-remote',
233 'title' =>
'Service / Termin',
234 'description' =>
'Terminleistung ohne Paketversand.',
235 'shipping_way' =>
'Termin / Remote / vor Ort nach Absprache',
236 'delivery_time' =>
'Nach Terminvereinbarung',
237 'shipping_gross' => 0,
238 'free_from_gross' => -1,
243 $this->updateChannelGroup(0, array(
244 'group_key' =>
'software-shop',
245 'title' =>
'Software Shop-Artikel',
246 'description' =>
'Softwarepakete fuer Shop und Amazon.',
249 ), array(
'shop',
'amazon'));
250 $this->updateChannelGroup(0, array(
251 'group_key' =>
'service-local',
252 'title' =>
'Service lokal',
253 'description' =>
'Dienstleistungen fuer Shop und Kleinanzeigen.',
256 ), array(
'shop',
'kleinanzeigen'));
259 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')),
260 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')),
262 foreach ($products as $p) {
264 $this->dd(
'shopProduct'),
270 'product_group_id' => $this->groupIdByKey($p[5]),
271 'product_type' => $p[4],
273 'description' => $p[9],
274 'price_gross' => $p[10],
276 'tax_mode' =>
'group',
278 'shipping_mode' =>
'group',
279 'shipping_gross' => -1,
284 'image_icon' => $p[14],
285 'logo_variant' =>
'',
287 'sku = ' . $this->sqlValue($p[0]),
290 $product = $this->productBySku($p[0],
false);
291 $productId = (int)($product[
'id'] ?? 0);
292 if ($productId <= 0)
continue;
293 $groupId = $this->groupIdByKey($p[5]);
295 $this->db()->save($this->dd(
'shopProductGroupMap'), array(
'product_id' => $productId,
'group_id' => $groupId,
'is_primary' => 1),
'product_id = ' . $productId .
' AND group_id = ' . $groupId, 0);
297 $shippingGroupId = $this->shippingGroupIdByKey($p[6]);
298 if ($shippingGroupId > 0) {
299 $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);
301 $channelGroupId = $this->channelGroupIdByKey($p[7]);
302 if ($channelGroupId > 0) {
303 $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);
305 foreach ($p[15] as $channelKey) {
306 $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);
310 $softwareGroupId = $this->groupIdByKey(
'software');
311 if ($softwareGroupId > 0) {
312 $this->saveImage(0, $softwareGroupId,
'files/shop/img/software-dashboard.svg',
'Software Dashboard',
'dbXapp Software Dashboard', 1, 10);
314 $serviceGroupId = $this->groupIdByKey(
'service');
315 if ($serviceGroupId > 0) {
316 $this->saveImage(0, $serviceGroupId,
'files/shop/img/service-support.svg',
'Service und Schulung',
'dbXapp Installation, Wartung und Schulung', 1, 10);
322 $this->seedDemoProductsWithDbxDb();
326 private function groupIdByKey(
string $key): int {
327 $row = $this->db()->select1($this->dd(
'shopProductGroup'),
'group_key = ' . $this->sqlValue($key),
'id', 0);
328 return (
int)($row[
'id'] ?? 0);
331 private function shippingGroupIdByKey(
string $key): int {
332 $row = $this->db()->select1($this->dd(
'shopShippingGroup'),
'group_key = ' . $this->sqlValue($key),
'id', 0);
333 return (
int)($row[
'id'] ?? 0);
336 private function channelGroupIdByKey(
string $key): int {
337 $row = $this->db()->select1($this->dd(
'shopChannelGroup'),
'group_key = ' . $this->sqlValue($key),
'id', 0);
338 return (
int)($row[
'id'] ?? 0);
341 private function valueNum(
string $value): ?float {
342 $clean = str_replace(
',',
'.', trim($value));
343 return is_numeric($clean) ? (float)$clean : null;
348 $where =
'trash = 0 AND group_id = ' . (int)$groupId;
350 $where .=
' AND active = 1';
352 $rows = $this->db()->select($this->dd(
'shopAttributeDefinition'), $where,
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
353 return is_array($rows) ? $rows : array();
361 $id = (int)($definition[
'id'] ?? 0);
362 if ($id <= 0 || isset($seen[$id]))
continue;
364 $defs[] = $definition;
372 $values = $this->db()->select($this->dd(
'shopProductAttributeValue'),
'product_id = ' . (
int)$productId .
' AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
373 $valueByAttribute = array();
374 foreach ((is_array($values) ? $values : array()) as $value) {
375 $valueByAttribute[(int)($value[
'attribute_id'] ?? 0)] = $value;
379 foreach ($this->attributeDefinitionsForProduct($productId,
true) as $definition) {
380 $attributeId = (int)($definition[
'id'] ?? 0);
381 if ($attributeId <= 0) {
384 $value = $valueByAttribute[$attributeId] ?? array();
385 $definition[
'value_text'] = $value[
'value_text'] ??
'';
386 $definition[
'value_num'] = $value[
'value_num'] ??
'';
387 $definition[
'unit_override'] = $value[
'unit_override'] ??
'';
388 $definition[
'value_active'] = $value[
'active'] ?? 0;
389 $rows[] = $definition;
391 foreach ($rows as &$row) {
392 $value = trim((
string)($row[
'value_text'] ??
''));
393 $unit = trim((
string)($row[
'unit_override'] ??
'')) ?: trim((
string)($row[
'unit'] ??
''));
394 $row[
'display_value'] = $value !==
'' && $unit !==
'' ? $value .
' ' . $unit : $value;
402 $groups = $this->
groups();
403 $groupById = array();
404 foreach ($groups as $group) {
405 $groupById[(int)($group[
'id'] ?? 0)] = $group;
407 $defs = $this->db()->select($this->dd(
'shopAttributeDefinition'),
'trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
408 $defs = is_array($defs) ? $defs : array();
409 foreach ($defs as &$def) {
410 $group = $groupById[(int)($def[
'group_id'] ?? 0)] ?? array();
411 $def[
'group_title'] = (string)($group[
'title'] ??
'');
412 $def[
'group_key'] = (string)($group[
'group_key'] ??
'');
413 $def[
'_group_sorter'] = (int)($group[
'sorter'] ?? 9999);
416 usort($defs, fn($a, $b) => ((
int)($a[
'_group_sorter'] ?? 9999) <=> (
int)($b[
'_group_sorter'] ?? 9999))
417 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
418 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
419 foreach ($defs as &$def) {
420 unset($def[
'_group_sorter']);
428 $defs = array_values(array_filter(
430 fn($def) => (
int)($def[
'active'] ?? 0) === 1 && (
int)($def[
'filterable'] ?? 0) === 1
432 $products = $this->db()->select($this->dd(
'shopProduct'),
'trash = 0 AND active = 1',
'id',
'',
'ASC',
'', 0, 0, 0);
433 $activeProducts = array();
434 foreach ((is_array($products) ? $products : array()) as $product) {
435 $activeProducts[(int)($product[
'id'] ?? 0)] =
true;
437 foreach ($defs as &$def) {
439 $rows = $this->db()->select(
440 $this->dd(
'shopProductAttributeValue'),
441 'attribute_id = ' . (
int)($def[
'id'] ?? 0) .
' AND trash = 0 AND active = 1',
450 foreach ((is_array($rows) ? $rows : array()) as $row) {
451 $productId = (int)($row[
'product_id'] ?? 0);
452 $value = trim((
string)($row[
'value_text'] ??
''));
453 if ($value !==
'' && isset($activeProducts[$productId])) {
454 $values[$value] = $value;
457 $values = array_values($values);
458 natcasesort($values);
459 $values = array_values($values);
460 if ($values === array() && trim((
string)($def[
'options'] ??
'')) !==
'') {
461 $values = preg_split(
'~[|;\r\n]+~', (
string)$def[
'options']) ?: array();
462 $values = array_values(array_filter(array_map(
'trim', $values), fn($v) => $v !==
''));
464 $def[
'values'] = $values;
472 $id = (int)($data[
'id'] ?? 0);
473 $groupId = (int)($data[
'group_id'] ?? 0);
474 $key = strtolower(trim(preg_replace(
'~[^a-z0-9_\\-]+~i',
'_', (
string)($data[
'attr_key'] ??
''))));
475 $title = trim((
string)($data[
'title'] ??
''));
476 if ($groupId <= 0 || $key ===
'' || $title ===
'') {
479 $type = (string)($data[
'input_type'] ??
'text');
480 if (!in_array($type, array(
'text',
'select',
'number'),
true)) {
484 'group_id' => $groupId,
487 'input_type' => $type,
488 'unit' => trim((
string)($data[
'unit'] ??
'')),
489 'options' => trim((
string)($data[
'options'] ??
'')),
490 'required' => !empty($data[
'required']) ? 1 : 0,
491 'filterable' => !empty($data[
'filterable']) ? 1 : 0,
492 'comparable' => !empty($data[
'comparable']) ? 1 : 0,
493 'active' => !empty($data[
'active']) ? 1 : 0,
494 'sorter' => (
int)($data[
'sorter'] ?? 100),
497 $this->db()->update($this->dd(
'shopAttributeDefinition'), $values,
'id = ' . (
int)$id, 0);
501 $this->dd(
'shopAttributeDefinition'),
503 'group_id = ' . (
int)$groupId .
' AND attr_key = ' . $this->sqlValue($key),
510 if ($productId <= 0 || $attributeId <= 0) {
513 $value = trim($value);
514 $num = $this->valueNum($value);
516 $this->dd(
'shopProductAttributeValue'),
518 'product_id' => $productId,
519 'attribute_id' => $attributeId,
520 'value_text' => $value,
522 'active' => $value !==
'' ? 1 : 0,
524 'product_id = ' . (
int)$productId .
' AND attribute_id = ' . (
int)$attributeId,
530 foreach ($values as $attributeId => $value) {
535 private function decorateProduct(array $row): array {
536 $row[
'groups'] = $this->groupsForProduct((int)$row[
'id']);
537 $row[
'shipping_groups'] = $this->shippingGroupsForProduct((
int)$row[
'id']);
538 $row[
'channel_groups'] = $this->channelGroupsForProduct((
int)$row[
'id']);
539 $row[
'channels'] = $this->channelsForProduct((
int)$row[
'id']);
540 $row[
'images'] = $this->imagesForProduct((
int)$row[
'id'], $row[
'groups']);
541 $row[
'attributes'] = $this->attributesForProduct((
int)$row[
'id']);
542 $primary = $row[
'groups'][0] ?? array();
543 $shipping = $row[
'shipping_groups'][0] ?? array();
544 $row[
'effective_tax_rate'] = $this->taxRateForClass(
545 (
string)($primary[
'tax_class'] ??
''),
546 (
float)($primary[
'default_tax_rate'] ?? 19)
548 $row[
'effective_shipping_gross'] = (string)($row[
'shipping_mode'] ??
'group') ===
'individual' && (float)($row[
'shipping_gross'] ?? -1) >= 0
549 ? (float)$row[
'shipping_gross']
550 : (float)($shipping[
'shipping_gross'] ?? $primary[
'default_shipping_gross'] ?? 0);
551 $row[
'effective_shipping_way'] = (string)($shipping[
'shipping_way'] ??
'');
552 $row[
'effective_delivery_time'] = trim((
string)($row[
'delivery_time'] ??
'')) !==
''
553 ? trim((
string)$row[
'delivery_time'])
554 : trim((string)($shipping[
'delivery_time'] ??
''));
558 private function taxRatesConfig(): array {
560 'mwst1' => array(
'title' =>
'MwSt. normal',
'rate' =>
'19'),
561 'mwst2' => array(
'title' =>
'MwSt. ermaessigt',
'rate' =>
'7'),
562 'mwst3' => array(
'title' =>
'MwSt. vorbereitet',
'rate' =>
'22'),
564 if (!function_exists(
'dbx')) {
567 $cfg =
dbx()->get_config(
'dbxShop',
'tax_rates', $fallback);
568 return is_array(
$cfg) && count(
$cfg) ?
$cfg : $fallback;
571 private function taxRateForClass(
string $taxClass,
float $fallback): float {
572 $taxClass = trim($taxClass);
573 $rates = $this->taxRatesConfig();
574 if ($taxClass !==
'' && isset($rates[$taxClass]) && is_array($rates[$taxClass])) {
575 return (
float)($rates[$taxClass][
'rate'] ?? $fallback);
577 $defaultClass = function_exists(
'dbx') ? (string)
dbx()->get_config(
'dbxShop',
'default_tax_class',
'mwst1') :
'mwst1';
578 if (isset($rates[$defaultClass]) && is_array($rates[$defaultClass])) {
579 return (
float)($rates[$defaultClass][
'rate'] ?? $fallback);
584 public function products(
bool $activeOnly =
true): array {
586 $where = $activeOnly ?
'active = 1 AND trash = 0' :
'trash = 0';
587 $rows = $this->db()->select($this->dd(
'shopProduct'), $where,
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
588 $rows = is_array($rows) ? $rows : array();
589 return array_map(fn($row) => $this->decorateProduct($row), $rows);
592 public function productBySku(
string $sku,
bool $activeOnly =
true): ?array {
594 $where =
'sku = ' . $this->sqlValue($sku) .
' AND trash = 0';
596 $where .=
' AND active = 1';
598 $row = $this->db()->select1($this->dd(
'shopProduct'), $where,
'*', 0);
599 return is_array($row) ? $this->decorateProduct($row) : null;
604 $row = $this->db()->select1($this->dd(
'shopProduct'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
605 return is_array($row) ? $this->decorateProduct($row) : null;
613 $row = $this->db()->select1($this->dd(
'shopProductGroup'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
614 return is_array($row) ? $row :
null;
617 private function stockEnabled(): bool {
618 $cfg = function_exists(
'dbx') ?
dbx()->get_config(
'dbxShop',
'', array()) : array();
619 return is_array(
$cfg) && !empty(
$cfg[
'stock_enabled']);
622 private function requiresStock(array $product): bool {
623 return $this->stockEnabled() && (string)($product[
'product_type'] ??
'') ===
'physical';
626 private function isPhysicalProduct(array $product): bool {
627 return (string)($product[
'product_type'] ??
'') ===
'physical';
633 foreach ($items as $sku => $qty) {
634 $qty = max(1, (
int)$qty);
636 if (!$product || !$this->requiresStock($product)) {
639 $stock = (int)($product[
'stock'] ?? 0);
642 'sku' => (
string)($product[
'sku'] ?? $sku),
643 'title' => (
string)($product[
'title'] ?? $sku),
652 private function hasReservableStockSnapshots(array $snapshots): bool {
653 if (!$this->stockEnabled()) {
656 foreach ($snapshots as $item) {
657 $productId = (int)($item[
'product_id'] ?? 0);
658 if ($productId <= 0) {
661 $product = $this->productById($productId);
662 if (!$product || !$this->requiresStock($product)) {
670 private function reserveStockForSnapshots(array $snapshots): int {
671 if (!$this->stockEnabled()) {
675 foreach ($snapshots as $item) {
676 $productId = (int)($item[
'product_id'] ?? 0);
677 if ($productId <= 0) {
680 $product = $this->productById($productId);
681 if (!$product || !$this->requiresStock($product)) {
684 $qty = max(1, (
int)($item[
'qty'] ?? 1));
685 $stock = max(0, (
int)($product[
'stock'] ?? 0) - $qty);
686 $this->db()->update($this->dd(
'shopProduct'), array(
688 'update_date' => date(
'Y-m-d H:i:s'),
689 ),
'id = ' . $productId .
' AND trash = 0', 0);
695 private function releaseStockForOrder(array $order,
string $reason): bool {
696 $orderId = (int)($order[
'id'] ?? 0);
697 if ($orderId <= 0 || (
int)($order[
'stock_reserved'] ?? 0) !== 1 || (
int)($order[
'stock_released'] ?? 0) === 1) {
702 foreach ((array)($order[
'items'] ?? array()) as $item) {
703 $productId = (int)($item[
'product_id'] ?? 0);
704 if ($productId <= 0) {
707 $product = $this->productById($productId);
708 if (!$product || !$this->isPhysicalProduct($product)) {
711 $qty = max(1, (
int)($item[
'qty'] ?? 1));
712 $stock = max(0, (
int)($product[
'stock'] ?? 0)) + $qty;
713 $this->db()->update($this->dd(
'shopProduct'), array(
715 'update_date' => date(
'Y-m-d H:i:s'),
716 ),
'id = ' . $productId .
' AND trash = 0', 0);
720 if ($released <= 0) {
724 $this->db()->update($this->dd(
'shopOrder'), array(
725 'stock_released' => 1,
726 'stock_released_date' => date(
'Y-m-d H:i:s'),
727 'update_date' => date(
'Y-m-d H:i:s'),
728 ),
'id = ' . $orderId .
' AND trash = 0', 0);
729 $this->addOrderHistory($orderId,
'stock_release',
'', (
string)$released, $reason);
733 private function normalizeProductIds(array $ids): array {
735 foreach ($ids as $id) {
741 return array_values($clean);
744 private function channelExists(
string $channelKey): bool {
745 $channelKey = trim($channelKey);
746 if ($channelKey ===
'') {
749 return $this->db()->count($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($channelKey) .
' AND trash = 0') > 0;
752 private function productGroupExists(
int $groupId): bool {
756 return $this->db()->count($this->dd(
'shopProductGroup'),
'id = ' . (
int)$groupId .
' AND trash = 0') > 0;
761 $ids = $this->normalizeProductIds($ids);
762 if ($ids === array()) {
766 $where =
'id IN (' . implode(
',', array_map(
'intval', $ids)) .
') AND trash = 0';
767 $count = (int)$this->db()->count($this->dd(
'shopProduct'), $where);
772 $this->dd(
'shopProduct'),
773 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
782 $ids = $this->normalizeProductIds($ids);
783 $channelKey = trim($channelKey);
784 if ($ids === array() || !$this->channelExists($channelKey)) {
789 foreach ($ids as $id) {
790 $ok = $this->db()->save(
791 $this->dd(
'shopProductChannel'),
792 array(
'product_id' => $id,
'channel_key' => $channelKey,
'active' => 1,
'channel_sku' =>
'',
'price_gross' => -1,
'shipping_gross' => -1),
793 'product_id = ' . (
int)$id .
' AND channel_key = ' . $this->sqlValue($channelKey),
797 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$id .
' AND trash = 0', 0);
806 $ids = $this->normalizeProductIds($ids);
807 $channelKey = trim($channelKey);
808 if ($ids === array() || !$this->channelExists($channelKey)) {
813 foreach ($ids as $id) {
814 $ok = $this->db()->save(
815 $this->dd(
'shopProductChannel'),
816 array(
'product_id' => $id,
'channel_key' => $channelKey,
'active' => 0,
'channel_sku' =>
'',
'price_gross' => -1,
'shipping_gross' => -1),
817 'product_id = ' . (
int)$id .
' AND channel_key = ' . $this->sqlValue($channelKey),
821 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$id .
' AND trash = 0', 0);
830 $ids = $this->normalizeProductIds($ids);
831 if ($ids === array() || !$this->productGroupExists($groupId)) {
836 foreach ($ids as $id) {
837 $this->db()->update($this->dd(
'shopProductGroupMap'), array(
'is_primary' => 0),
'product_id = ' . (
int)$id, 0);
838 $ok = $this->db()->save(
839 $this->dd(
'shopProductGroupMap'),
840 array(
'product_id' => $id,
'group_id' => $groupId,
'is_primary' => 1),
841 'product_id = ' . (
int)$id .
' AND group_id = ' . (
int)$groupId,
845 $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);
853 $product = $this->db()->select1($this->dd(
'shopProduct'),
'id = ' . (int)$productId .
' AND trash = 0',
'product_group_id', 0);
854 $directGroupId = (int)($product[
'product_group_id'] ?? 0);
855 if ($directGroupId > 0) {
856 $group = $this->
groupById($directGroupId);
857 if (is_array($group)) {
858 return array($group);
862 $maps = $this->db()->select($this->dd(
'shopProductGroupMap'),
'product_id = ' . (
int)$productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 0, 0);
863 if (!is_array($maps) || $maps === array()) {
866 $groupIds = array_values(array_unique(array_map(fn($row) => (
int)($row[
'group_id'] ?? 0), $maps)));
867 $groupIds = array_values(array_filter($groupIds, fn($id) => $id > 0));
868 if ($groupIds === array()) {
871 $groups = $this->db()->select($this->dd(
'shopProductGroup'),
'id IN (' . implode(
',', $groupIds) .
') AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
873 foreach ((is_array($groups) ? $groups : array()) as $group) {
874 $byId[(int)($group[
'id'] ?? 0)] = $group;
877 foreach ($maps as $map) {
878 $id = (int)($map[
'group_id'] ?? 0);
879 if (isset($byId[$id])) {
881 $row[
'_is_primary'] = (int)($map[
'is_primary'] ?? 0);
885 usort($rows, fn($a, $b) => ((
int)($b[
'_is_primary'] ?? 0) <=> (
int)($a[
'_is_primary'] ?? 0))
886 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
887 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
888 foreach ($rows as &$row) {
889 unset($row[
'_is_primary']);
896 $channelIndex = array();
897 foreach ($this->
channels() as $channel) {
898 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
902 $direct = $this->db()->select($this->dd(
'shopProductChannel'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
903 foreach ((is_array($direct) ? $direct : array()) as $row) {
904 $key = (string)($row[
'channel_key'] ??
'');
905 $base = $channelIndex[$key] ?? array(
'channel_key' => $key,
'title' => $key,
'sorter' => 9999);
907 'channel_key' => $key,
908 'title' => (
string)(
$base[
'title'] ?? $key),
909 'active' => (
int)($row[
'active'] ?? 0),
910 'channel_sku' => (
string)($row[
'channel_sku'] ??
''),
911 'price_gross' => (
float)($row[
'price_gross'] ?? -1),
912 'shipping_gross' => (
float)($row[
'shipping_gross'] ?? -1),
913 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
916 usort($channels, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
917 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
919 $groupMaps = $this->db()->select($this->dd(
'shopProductChannelGroupMap'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
920 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
921 $groupId = (int)($groupMap[
'channel_group_id'] ?? 0);
922 if ($groupId <= 0)
continue;
923 $groupChannels = $this->db()->select($this->dd(
'shopChannelGroupChannel'),
'channel_group_id = ' . $groupId .
' AND active = 1',
'*',
'',
'ASC',
'', 0, 0, 0);
924 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
925 $key = (string)($row[
'channel_key'] ??
'');
926 $base = $channelIndex[$key] ?? array(
'channel_key' => $key,
'title' => $key,
'sorter' => 9999);
928 'channel_key' => $key,
929 'title' => (
string)(
$base[
'title'] ?? $key),
933 'shipping_gross' => -1,
934 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
941 foreach ($channels as $channel) {
942 $key = (string)($channel[
'channel_key'] ??
'');
943 if ($key ===
'' || isset($seen[$key]))
continue;
945 if ((
int)($channel[
'active'] ?? 0) === 1) {
946 unset($channel[
'_sorter']);
955 $channelIndex = array();
956 foreach ($this->
channels() as $channel) {
957 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
959 $rows = $this->db()->select($this->dd(
'shopProductChannel'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
960 $rows = is_array($rows) ? $rows : array();
961 foreach ($rows as &$row) {
962 $key = (string)($row[
'channel_key'] ??
'');
963 $row[
'title'] = (string)($channelIndex[$key][
'title'] ?? $key);
964 $row[
'_sorter'] = (int)($channelIndex[$key][
'sorter'] ?? 9999);
967 usort($rows, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
968 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
970 foreach ($rows as $row) {
971 $key = (string)($row[
'channel_key'] ??
'');
973 unset($row[
'_sorter']);
982 $channelIndex = array();
983 foreach ($this->
channels() as $channel) {
984 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
986 $groupMaps = $this->db()->select($this->dd(
'shopProductChannelGroupMap'),
'product_id = ' . (
int)$productId,
'*',
'',
'ASC',
'', 0, 0, 0);
988 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
989 $groupId = (int)($groupMap[
'channel_group_id'] ?? 0);
990 if ($groupId <= 0)
continue;
991 $group = $this->db()->select1($this->dd(
'shopChannelGroup'),
'id = ' . $groupId .
' AND trash = 0 AND active = 1',
'*', 0);
992 if (!is_array($group))
continue;
993 $groupChannels = $this->db()->select($this->dd(
'shopChannelGroupChannel'),
'channel_group_id = ' . $groupId .
' AND active = 1',
'*',
'',
'ASC',
'', 0, 0, 0);
994 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
995 $key = (string)($row[
'channel_key'] ??
'');
996 $base = $channelIndex[$key] ?? array(
'channel_key' => $key,
'title' => $key,
'sorter' => 9999);
998 'channel_key' => $key,
999 'title' => (
string)(
$base[
'title'] ?? $key),
1000 'active' => (
int)($row[
'active'] ?? 0),
1001 'group_title' => (
string)($group[
'title'] ??
''),
1002 '_sorter' => (
int)(
$base[
'sorter'] ?? 9999),
1006 usort($rows, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
1007 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
1009 foreach ($rows as $row) {
1010 $key = (string)($row[
'channel_key'] ??
'');
1014 if (!isset($out[$key])) {
1016 $out[$key][
'group_titles'] = array();
1018 $groupTitle = trim((
string)($row[
'group_title'] ??
''));
1019 if ($groupTitle !==
'') {
1020 $out[$key][
'group_titles'][$groupTitle] = $groupTitle;
1022 unset($out[$key][
'_sorter']);
1029 $productId = max(0, $productId);
1030 if ($productId <= 0) {
1034 $product = $this->productById($productId);
1040 foreach ($activeChannelKeys as $key) {
1041 $key = trim((
string)$key);
1043 $active[$key] =
true;
1047 foreach ($this->channels() as $channel) {
1048 $key = trim((
string)($channel[
'channel_key'] ??
''));
1052 $existing = $this->db()->select1(
1053 $this->dd(
'shopProductChannel'),
1054 'product_id = ' . (
int)$productId .
' AND channel_key = ' . $this->sqlValue($key),
1058 $existing = is_array($existing) ? $existing : array();
1059 $channelSku = trim((
string)($existing[
'channel_sku'] ??
''));
1060 if ($channelSku ===
'') {
1061 $channelSku = (string)($product[
'sku'] ??
'');
1064 $this->dd(
'shopProductChannel'),
1066 'product_id' => $productId,
1067 'channel_key' => $key,
1068 'active' => isset($active[$key]) ? 1 : 0,
1069 'channel_sku' => $channelSku,
1070 'price_gross' => (
float)($existing[
'price_gross'] ?? -1),
1071 'shipping_gross' => (
float)($existing[
'shipping_gross'] ?? -1),
1072 'external_listing_id' => (
string)($existing[
'external_listing_id'] ??
''),
1073 'external_offer_id' => (
string)($existing[
'external_offer_id'] ??
''),
1074 'export_status' => (
string)($existing[
'export_status'] ??
''),
1075 'export_message' => (
string)($existing[
'export_message'] ??
''),
1076 'export_payload' => (
string)($existing[
'export_payload'] ??
''),
1077 'last_export_date' => (
string)($existing[
'last_export_date'] ??
''),
1079 'product_id = ' . (
int)$productId .
' AND channel_key = ' . $this->sqlValue($key),
1084 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . (
int)$productId .
' AND trash = 0', 0);
1088 $maps = $this->db()->select($this->dd(
'shopProductShippingGroupMap'),
'product_id = ' . (int)$productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 0, 0);
1089 if (!is_array($maps) || $maps === array()) {
1092 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (
int)($row[
'shipping_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1093 if ($ids === array()) {
1096 $groups = $this->db()->select($this->dd(
'shopShippingGroup'),
'id IN (' . implode(
',', $ids) .
') AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
1098 foreach ((is_array($groups) ? $groups : array()) as $group) {
1099 $byId[(int)($group[
'id'] ?? 0)] = $group;
1102 foreach ($maps as $map) {
1103 $id = (int)($map[
'shipping_group_id'] ?? 0);
1104 if (isset($byId[$id])) {
1106 $row[
'_is_primary'] = (int)($map[
'is_primary'] ?? 0);
1110 usort($rows, fn($a, $b) => ((
int)($b[
'_is_primary'] ?? 0) <=> (
int)($a[
'_is_primary'] ?? 0))
1111 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
1112 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
1113 foreach ($rows as &$row) {
1114 unset($row[
'_is_primary']);
1121 $maps = $this->db()->select($this->dd(
'shopProductChannelGroupMap'),
'product_id = ' . (int)$productId,
'*',
'is_primary DESC',
'ASC',
'', 0, 0, 0);
1122 if (!is_array($maps) || $maps === array()) {
1125 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (
int)($row[
'channel_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1126 if ($ids === array()) {
1129 $groups = $this->db()->select($this->dd(
'shopChannelGroup'),
'id IN (' . implode(
',', $ids) .
') AND trash = 0',
'*',
'',
'ASC',
'', 0, 0, 0);
1131 foreach ((is_array($groups) ? $groups : array()) as $group) {
1132 $byId[(int)($group[
'id'] ?? 0)] = $group;
1135 foreach ($maps as $map) {
1136 $id = (int)($map[
'channel_group_id'] ?? 0);
1137 if (isset($byId[$id])) {
1139 $row[
'_is_primary'] = (int)($map[
'is_primary'] ?? 0);
1143 usort($rows, fn($a, $b) => ((
int)($b[
'_is_primary'] ?? 0) <=> (
int)($a[
'_is_primary'] ?? 0))
1144 ?: ((
int)($a[
'sorter'] ?? 0) <=> (
int)($b[
'sorter'] ?? 0))
1145 ?: strcasecmp((
string)($a[
'title'] ??
''), (
string)($b[
'title'] ??
'')));
1146 foreach ($rows as &$row) {
1147 unset($row[
'_is_primary']);
1155 $rows = $this->db()->select($this->dd(
'shopProductGroup'),
'trash = 0',
'*',
'parent_id ASC, sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1156 return is_array($rows) ? $rows : array();
1161 $where =
'trash = 0 AND parent_id = ' . max(0, (
int)$parentId);
1163 $where .=
' AND active = 1';
1165 $rows = $this->db()->select($this->dd(
'shopProductGroup'), $where,
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1166 return is_array($rows) ? $rows : array();
1173 $current = max(0, (
int)$groupId);
1174 while ($current > 0 && !isset($seen[$current])) {
1175 $seen[$current] =
true;
1177 if (!is_array($group)) {
1180 array_unshift($path, $group);
1181 $current = (int)($group[
'parent_id'] ?? 0);
1186 private function wouldCreateGroupCycle(
int $groupId,
int $parentId): bool {
1187 if ($groupId <= 0 || $parentId <= 0) {
1190 if ($groupId === $parentId) {
1193 $seen = array($groupId =>
true);
1194 $current = $parentId;
1195 while ($current > 0 && !isset($seen[$current])) {
1196 $seen[$current] =
true;
1197 $group = $this->groupById($current);
1198 if (!is_array($group)) {
1201 $current = (int)($group[
'parent_id'] ?? 0);
1202 if ($current === $groupId) {
1209 private function nextGroupSorter(
int $parentId): int {
1210 $rows = $this->db()->select(
1211 $this->dd(
'shopProductGroup'),
1212 'trash = 0 AND parent_id = ' . max(0, (int)$parentId),
1221 $max = is_array($rows) && isset($rows[0]) ? (int)($rows[0][
'sorter'] ?? 0) : 0;
1227 $groupId = max(0, (
int)$groupId);
1228 $parentId = max(0, (
int)$parentId);
1229 if ($groupId <= 0 || !is_array($this->
groupById($groupId))) {
1232 if ($parentId > 0 && !is_array($this->groupById($parentId))) {
1235 if ($this->wouldCreateGroupCycle($groupId, $parentId)) {
1238 $this->db()->update(
1239 $this->dd(
'shopProductGroup'),
1241 'parent_id' => $parentId,
1242 'sorter' => $this->nextGroupSorter($parentId),
1243 'update_date' => date(
'Y-m-d H:i:s'),
1245 'id = ' . $groupId .
' AND trash = 0',
1253 $rows = $this->db()->select($this->dd(
'shopChannel'),
'trash = 0',
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1254 return is_array($rows) ? $rows : array();
1262 $row = $this->db()->select1($this->dd(
'shopChannel'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
1263 return is_array($row) ? $row :
null;
1272 $row = $this->db()->select1($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($key) .
' AND trash = 0',
'*', 0);
1273 return is_array($row) ? $row :
null;
1278 $secretFields = array(
'api_client_secret',
'api_access_token',
'api_refresh_token',
'api_password',
'webhook_secret');
1279 $existing = $id > 0 ? ($this->
channelById($id) ?: array()) : array();
1281 'title' => (
string)($data[
'title'] ??
''),
1282 'description' => (
string)($data[
'description'] ??
''),
1283 'platform_type' => (
string)($data[
'platform_type'] ??
'custom'),
1284 'connection_mode' => (
string)($data[
'connection_mode'] ??
'manual'),
1285 'api_base_url' => (
string)($data[
'api_base_url'] ??
''),
1286 'api_client_id' => (
string)($data[
'api_client_id'] ??
''),
1287 'api_username' => (
string)($data[
'api_username'] ??
''),
1288 'marketplace_id' => (
string)($data[
'marketplace_id'] ??
''),
1289 'seller_id' => (
string)($data[
'seller_id'] ??
''),
1290 'account_id' => (
string)($data[
'account_id'] ??
''),
1291 'location_key' => (
string)($data[
'location_key'] ??
''),
1292 'category_id' => (
string)($data[
'category_id'] ??
''),
1293 'payment_policy_id' => (
string)($data[
'payment_policy_id'] ??
''),
1294 'fulfillment_policy_id' => (
string)($data[
'fulfillment_policy_id'] ??
''),
1295 'return_policy_id' => (
string)($data[
'return_policy_id'] ??
''),
1296 'notification_destination' => (
string)($data[
'notification_destination'] ??
''),
1297 'notification_topic' => (
string)($data[
'notification_topic'] ??
''),
1298 'api_scope' => (
string)($data[
'api_scope'] ??
''),
1299 'export_enabled' => !empty($data[
'export_enabled']) ? 1 : 0,
1300 'order_import_enabled' => !empty($data[
'order_import_enabled']) ? 1 : 0,
1301 'active' => !empty($data[
'active']) ? 1 : 0,
1302 'sorter' => (
int)($data[
'sorter'] ?? 100),
1304 foreach ($secretFields as
$field) {
1305 $posted = (string)($data[
$field] ??
'');
1306 $values[
$field] = ($id > 0 && $posted ===
'') ? (
string)($existing[
$field] ??
'') : $posted;
1310 $channelKey = $this->normalizeKey((
string)($data[
'channel_key'] ??
''));
1311 if ($channelKey ===
'') {
1312 $channelKey = $this->normalizeKey((
string)($data[
'title'] ??
'channel'));
1314 if ($channelKey ===
'') $channelKey =
'channel';
1315 $channelKey = $this->uniqueChannelKey($channelKey);
1316 $values[
'channel_key'] = $channelKey;
1317 $this->db()->insert($this->dd(
'shopChannel'), $values, 0);
1321 $this->db()->update($this->dd(
'shopChannel'), $values,
'id = ' . (
int)$id, 0);
1330 $channel = $this->channelById($id);
1335 $updated = (int)$this->db()->update(
1336 $this->dd(
'shopChannel'),
1337 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
1338 'id = ' . (
int)$id .
' AND trash = 0',
1341 if ($updated !== 1) {
1345 $this->db()->update(
1346 $this->dd(
'shopProductChannel'),
1347 array(
'active' => 0),
1348 'channel_key = ' . $this->sqlValue((
string)($channel[
'channel_key'] ??
'')),
1358 return array(
'ok' =>
false,
'message' =>
'Channel wurde nicht gefunden.');
1361 $connector =
dbx()->get_include_obj(
'dbxShopChannelConnector',
'dbxShop');
1362 $result = is_object($connector) && method_exists($connector,
'test')
1363 ? (array)$connector->test($channel)
1364 : array(
'ok' =>
false,
'message' =>
'Channel-Connector konnte nicht geladen werden.');
1367 $message = (string)(
$result[
'message'] ??
'');
1368 $this->saveChannelTestResult($id, $ok, $message);
1369 return array(
'ok' => $ok,
'message' => $message);
1372 private function productHasActiveChannel(array $product,
string $channelKey): bool {
1373 foreach ((array)($product[
'channels'] ?? array()) as $channel) {
1374 if ((
string)($channel[
'channel_key'] ??
'') === $channelKey && (
int)($channel[
'active'] ?? 0) === 1) {
1381 private function productChannelRowForExport(array $product,
string $channelKey): array {
1382 $productId = (int)($product[
'id'] ?? 0);
1383 $row = $this->db()->select1(
1384 $this->dd(
'shopProductChannel'),
1385 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
1389 if (is_array($row)) {
1394 'product_id' => $productId,
1395 'channel_key' => $channelKey,
1397 'channel_sku' => (
string)($product[
'sku'] ??
''),
1398 'price_gross' => -1,
1399 'shipping_gross' => -1,
1400 'export_status' =>
'ready',
1403 $this->dd(
'shopProductChannel'),
1405 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
1408 $row = $this->db()->select1(
1409 $this->dd(
'shopProductChannel'),
1410 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
1414 return is_array($row) ? $row : $values;
1423 $channel = $this->channelByKey($channelKey);
1427 $row = $this->db()->select1(
1428 $this->dd(
'shopProductChannel'),
1429 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
1433 if (!is_array($row)) {
1435 'product_id' => $productId,
1436 'channel_key' => $channelKey,
1437 'active' => $this->productHasActiveChannel($product, $channelKey) ? 1 : 0,
1438 'channel_sku' => (
string)($product[
'sku'] ??
''),
1439 'price_gross' => -1,
1440 'shipping_gross' => -1,
1444 $note = trim((
string)($row[
'note'] ??
''));
1445 $mapping = $note !==
'' ? json_decode($note,
true) : array();
1446 if (!is_array($mapping)) {
1450 'product' => $product,
1451 'channel' => $channel,
1452 'product_channel' => $row,
1453 'mapping' => $mapping,
1461 if (!$product || !$channel) {
1465 $mapping = is_array($data[
'mapping'] ??
null) ? $data[
'mapping'] : array();
1466 $note = json_encode($mapping, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
1467 if ($note ===
false) {
1471 $existing = $this->productChannelRowForExport($product, $channelKey);
1473 $this->dd(
'shopProductChannel'),
1475 'product_id' => $productId,
1476 'channel_key' => $channelKey,
1477 'active' => !empty($data[
'active']) ? 1 : 0,
1478 'channel_sku' => trim((
string)($data[
'channel_sku'] ?? $product[
'sku'] ??
'')),
1479 'price_gross' => (
float)($data[
'price_gross'] ?? -1),
1480 'shipping_gross' => (
float)($data[
'shipping_gross'] ?? -1),
1481 'external_listing_id' => trim((
string)($data[
'external_listing_id'] ?? $existing[
'external_listing_id'] ??
'')),
1482 'external_offer_id' => trim((
string)($data[
'external_offer_id'] ?? $existing[
'external_offer_id'] ??
'')),
1483 'export_status' => (
string)($existing[
'export_status'] ??
''),
1484 'export_message' => (
string)($existing[
'export_message'] ??
''),
1485 'export_payload' => (
string)($existing[
'export_payload'] ??
''),
1486 'last_export_date' => (
string)($existing[
'last_export_date'] ??
''),
1489 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
1492 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . $productId .
' AND trash = 0', 0);
1495 private function saveProductChannelExportResult(
int $productId,
string $channelKey, array
$result): void {
1496 $payload =
$result[
'payload'] ?? array();
1497 $payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1498 if ($payloadJson ===
false) {
1502 $this->dd(
'shopProductChannel'),
1504 'product_id' => $productId,
1505 'channel_key' => $channelKey,
1507 'external_listing_id' => (
string)(
$result[
'external_listing_id'] ??
''),
1508 'external_offer_id' => (
string)(
$result[
'external_offer_id'] ??
''),
1509 'export_status' => (
string)(
$result[
'status'] ?? (!empty(
$result[
'ok']) ?
'exported' :
'failed')),
1510 'export_message' => (
string)(
$result[
'message'] ??
''),
1511 'export_payload' => $payloadJson,
1512 'last_export_date' => date(
'Y-m-d H:i:s'),
1514 'product_id = ' . $productId .
' AND channel_key = ' . $this->sqlValue($channelKey),
1517 $this->db()->update($this->dd(
'shopProduct'), array(
'update_date' => date(
'Y-m-d H:i:s')),
'id = ' . $productId .
' AND trash = 0', 0);
1522 $channelKey = trim($channelKey);
1525 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Artikel wurde nicht gefunden.');
1527 $channel = $this->channelByKey($channelKey);
1528 if (!$channel || (
int)($channel[
'active'] ?? 0) !== 1) {
1529 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Channel ist nicht aktiv oder wurde nicht gefunden.');
1531 if ((
int)($channel[
'export_enabled'] ?? 0) !== 1) {
1532 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Export ist fuer diesen Channel nicht aktiv.');
1534 if (!$this->productHasActiveChannel($product, $channelKey)) {
1535 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Artikel ist diesem Channel nicht aktiv zugeordnet.');
1538 $productChannel = $this->productChannelRowForExport($product, $channelKey);
1539 $connector =
dbx()->get_include_obj(
'dbxShopChannelConnector',
'dbxShop');
1540 $result = is_object($connector) && method_exists($connector,
'exportProduct')
1541 ? (array)$connector->exportProduct($channel, $product, $productChannel)
1542 : array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'Channel-Export-Connector konnte nicht geladen werden.');
1543 $this->saveProductChannelExportResult((
int)$product[
'id'], $channelKey,
$result);
1549 $ids = $this->normalizeProductIds($ids);
1550 $summary = array(
'total' => count($ids),
'ok' => 0,
'failed' => 0,
'messages' => array());
1551 foreach ($ids as $id) {
1556 $summary[
'failed']++;
1558 $message = trim((
string)(
$result[
'message'] ??
''));
1559 if ($message !==
'') {
1560 $summary[
'messages'][] =
'#' . $id .
': ' . $message;
1566 private function saveChannelTestResult(
int $id,
bool $ok,
string $message): void {
1567 $now = date(
'Y-m-d H:i:s');
1568 $this->db()->update(
1569 $this->dd(
'shopChannel'),
1571 'test_status' => $ok ?
'ok' :
'error',
1572 'test_message' => $message,
1573 'last_test_date' => $now,
1574 'update_date' => $now,
1583 $rows = $this->db()->select($this->dd(
'shopShippingGroup'),
'trash = 0',
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1584 return is_array($rows) ? $rows : array();
1589 $groups = $this->db()->select($this->dd(
'shopChannelGroup'),
'trash = 0',
'*',
'sorter ASC, title ASC',
'ASC',
'', 0, 0, 0);
1590 $groups = is_array($groups) ? $groups : array();
1591 foreach ($groups as &$group) {
1599 $channelIndex = array();
1600 foreach ($this->
channels() as $channel) {
1601 $channelIndex[(string)($channel[
'channel_key'] ??
'')] = $channel;
1603 $rows = $this->db()->select($this->dd(
'shopChannelGroupChannel'),
'channel_group_id = ' . (
int)$channelGroupId,
'*',
'',
'ASC',
'', 0, 0, 0);
1604 $rows = is_array($rows) ? $rows : array();
1605 foreach ($rows as &$row) {
1606 $key = (string)($row[
'channel_key'] ??
'');
1607 $row[
'title'] = (string)($channelIndex[$key][
'title'] ?? $key);
1608 $row[
'_sorter'] = (int)($channelIndex[$key][
'sorter'] ?? 9999);
1611 usort($rows, fn($a, $b) => ((
int)($a[
'_sorter'] ?? 9999) <=> (
int)($b[
'_sorter'] ?? 9999))
1612 ?: strcasecmp((
string)($a[
'channel_key'] ??
''), (
string)($b[
'channel_key'] ??
'')));
1613 foreach ($rows as &$row) {
1614 unset($row[
'_sorter']);
1622 $parentId = max(0, (
int)($data[
'parent_id'] ?? 0));
1623 if ($id > 0 && $parentId === $id) {
1627 'parent_id' => $parentId,
1628 'title' => (
string)($data[
'title'] ??
''),
1629 'description' => (
string)($data[
'description'] ??
''),
1630 'tax_class' => (
string)($data[
'tax_class'] ??
'mwst1'),
1631 'default_tax_rate' => $this->taxRateForClass((
string)($data[
'tax_class'] ??
'mwst1'), (
float)($data[
'default_tax_rate'] ?? 19)),
1632 'display_variant' => (
string)($data[
'display_variant'] ??
'gallery_grid'),
1633 'card_template' => (
string)($data[
'card_template'] ??
'product-card-default'),
1634 'detail_template' => (
string)($data[
'detail_template'] ??
'product-detail-default'),
1635 'gallery_template' => (
string)($data[
'gallery_template'] ??
'image-gallery'),
1636 'gallery_visible_count' => max(1, (
int)($data[
'gallery_visible_count'] ?? 3)),
1637 'gallery_image_size' => (
string)($data[
'gallery_image_size'] ??
'original'),
1638 'gallery_lightbox_width' => (
string)($data[
'gallery_lightbox_width'] ??
'100vw'),
1639 'gallery_overflow' => (
string)($data[
'gallery_overflow'] ??
'grid'),
1640 'gallery_click' => (
string)($data[
'gallery_click'] ??
'lightbox'),
1641 'attribute_notes' => (
string)($data[
'attribute_notes'] ??
''),
1642 'ebay_category_id' => (
string)($data[
'ebay_category_id'] ??
''),
1643 'amazon_product_type' => (
string)($data[
'amazon_product_type'] ??
''),
1644 'kleinanzeigen_category_id' => (
string)($data[
'kleinanzeigen_category_id'] ??
''),
1645 'mobile_category_id' => (
string)($data[
'mobile_category_id'] ??
''),
1646 'active' => !empty($data[
'active']) ? 1 : 0,
1647 'sorter' => (
int)($data[
'sorter'] ?? 100),
1650 $groupKey = $this->normalizeKey((
string)($data[
'group_key'] ??
''));
1651 if ($groupKey ===
'') {
1652 $groupKey = $this->normalizeKey((
string)($data[
'title'] ??
'artikelgruppe'));
1654 if ($groupKey ===
'') $groupKey =
'artikelgruppe';
1655 $groupKey = $this->uniqueProductGroupKey($groupKey);
1656 $values[
'group_key'] = $groupKey;
1657 $this->db()->insert($this->dd(
'shopProductGroup'), $values, 0);
1661 $this->db()->update($this->dd(
'shopProductGroup'), $values,
'id = ' . (
int)$id, 0);
1667 'title' => (
string)($data[
'title'] ??
''),
1668 'description' => (
string)($data[
'description'] ??
''),
1669 'shipping_way' => (
string)($data[
'shipping_way'] ??
''),
1670 'delivery_time' => (
string)($data[
'delivery_time'] ??
''),
1671 'shipping_gross' => (
float)($data[
'shipping_gross'] ?? 0),
1672 'free_from_gross' => (
float)($data[
'free_from_gross'] ?? -1),
1673 'active' => !empty($data[
'active']) ? 1 : 0,
1674 'sorter' => (
int)($data[
'sorter'] ?? 100),
1677 $groupKey = $this->normalizeKey((
string)($data[
'group_key'] ??
''));
1678 if ($groupKey ===
'') {
1679 $groupKey = $this->normalizeKey((
string)($data[
'title'] ??
'versandgruppe'));
1681 if ($groupKey ===
'') $groupKey =
'versandgruppe';
1682 $groupKey = $this->uniqueShippingGroupKey($groupKey);
1683 $values[
'group_key'] = $groupKey;
1684 $this->db()->insert($this->dd(
'shopShippingGroup'), $values, 0);
1688 $this->db()->update($this->dd(
'shopShippingGroup'), $values,
'id = ' . (
int)$id, 0);
1694 'title' => (
string)($data[
'title'] ??
''),
1695 'description' => (
string)($data[
'description'] ??
''),
1696 'active' => !empty($data[
'active']) ? 1 : 0,
1697 'sorter' => (
int)($data[
'sorter'] ?? 100),
1700 $groupKey = $this->normalizeKey((
string)($data[
'group_key'] ??
''));
1701 if ($groupKey ===
'') {
1702 $groupKey = $this->normalizeKey((
string)($data[
'title'] ??
'channel-gruppe'));
1704 if ($groupKey ===
'') $groupKey =
'channel-gruppe';
1705 $groupKey = $this->uniqueChannelGroupKey($groupKey);
1706 $values[
'group_key'] = $groupKey;
1707 if ($values[
'title'] ===
'') {
1708 $values[
'title'] = $groupKey;
1710 $this->db()->insert($this->dd(
'shopChannelGroup'), $values, 0);
1711 $row = $this->db()->select1($this->dd(
'shopChannelGroup'),
'group_key = ' . $this->sqlValue($groupKey),
'id', 0);
1712 $id = (int)($row[
'id'] ?? 0);
1714 $this->db()->update($this->dd(
'shopChannelGroup'), $values,
'id = ' . (
int)$id, 0);
1720 $channels = $this->channels();
1721 foreach ($channels as $channel) {
1722 $key = (string)($channel[
'channel_key'] ??
'');
1723 if ($key ===
'')
continue;
1724 $active = in_array($key, $channelKeys,
true) ? 1 : 0;
1726 $this->dd(
'shopChannelGroupChannel'),
1727 array(
'channel_group_id' => $id,
'channel_key' => $key,
'active' => $active),
1728 'channel_group_id = ' . (
int)$id .
' AND channel_key = ' . $this->sqlValue($key),
1740 $updated = (int)$this->db()->update(
1741 $this->dd(
'shopChannelGroup'),
1742 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
1743 'id = ' . (
int)$id .
' AND trash = 0',
1746 if ($updated !== 1) {
1750 $this->db()->update($this->dd(
'shopChannelGroupChannel'), array(
'active' => 0),
'channel_group_id = ' . (
int)$id, 0);
1760 return (
int)$this->db()->update(
1761 $this->dd(
'shopProductGroup'),
1762 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
1763 'id = ' . (
int)$id .
' AND trash = 0',
1774 return (
int)$this->db()->update(
1775 $this->dd(
'shopShippingGroup'),
1776 array(
'trash' => 1,
'active' => 0,
'update_date' => date(
'Y-m-d H:i:s')),
1777 'id = ' . (
int)$id .
' AND trash = 0',
1782 private function normalizeKey(
string $value): string {
1783 $value = strtolower(trim($value));
1784 $value = preg_replace(
'~[^a-z0-9_-]+~',
'-', $value);
1785 $value = trim((
string)$value,
'-_');
1789 private function uniqueProductGroupKey(
string $baseKey): string {
1790 return $this->uniqueKey(
'shop_product_group', $baseKey);
1793 private function uniqueShippingGroupKey(
string $baseKey): string {
1794 return $this->uniqueKey(
'shop_shipping_group', $baseKey);
1797 private function uniqueChannelKey(
string $baseKey): string {
1798 $key = $baseKey !==
'' ? $baseKey :
'channel';
1801 if ($this->db()->count($this->dd(
'shopChannel'),
'channel_key = ' . $this->sqlValue($key)) <= 0)
return $key;
1802 $key = $baseKey .
'-' . $suffix;
1807 private function uniqueChannelGroupKey(
string $baseKey): string {
1808 return $this->uniqueKey(
'shop_channel_group', $baseKey);
1811 private function uniqueKey(
string $table,
string $baseKey): string {
1813 'shop_product_group' => $this->dd(
'shopProductGroup'),
1814 'shop_shipping_group' => $this->dd(
'shopShippingGroup'),
1815 'shop_channel_group' => $this->dd(
'shopChannelGroup'),
1817 if (!isset($ddMap[
$table])) {
1820 $key = $baseKey !==
'' ? $baseKey :
'gruppe';
1823 if ($this->db()->count($ddMap[
$table],
'group_key = ' . $this->sqlValue($key)) <= 0)
return $key;
1824 $key = $baseKey .
'-' . $suffix;
1832 if ($productId > 0) {
1833 $rows = $this->db()->select(
1834 $this->dd(
'shopProductImage'),
1835 'trash = 0 AND active = 1 AND product_id = ' . (
int)$productId,
1837 'is_primary DESC, sorter ASC, title ASC',
1844 $images = is_array($rows) ? $rows : array();
1847 $groupIds = array();
1848 foreach ($groups as $group) {
1849 $id = (int)($group[
'id'] ?? 0);
1850 if ($id > 0) $groupIds[] = $id;
1852 if ($groupIds !== array()) {
1853 $rows = $this->db()->select(
1854 $this->dd(
'shopProductImage'),
1855 'trash = 0 AND active = 1 AND group_id IN (' . implode(
',', array_map(
'intval', $groupIds)) .
')',
1857 'is_primary DESC, sorter ASC, title ASC',
1864 $images = array_merge($images, is_array($rows) ? $rows : array());
1869 foreach ($images as $image) {
1870 $path = (string)($image[
'image_path'] ??
'');
1871 $mediaId = (int)($image[
'media_id'] ?? 0);
1872 $key = $mediaId > 0 ?
'm:' . $mediaId :
'p:' . $path;
1873 if (($mediaId <= 0 && $path ===
'') || isset($seen[$key]))
continue;
1883 $products = $this->db()->select($this->dd(
'shopProduct'),
'trash = 0',
'id',
'',
'ASC',
'', 0, 0, 0);
1884 foreach ((is_array($products) ? $products : array()) as $product) {
1885 $productId = (int)($product[
'id'] ?? 0);
1886 if ($productId <= 0)
continue;
1895 $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);
1896 $rows = is_array($rows) ? $rows : array();
1897 foreach ($rows as &$row) {
1898 $productId = (int)($row[
'product_id'] ?? 0);
1899 $groupId = (int)($row[
'group_id'] ?? 0);
1900 $product = $productId > 0 ? $this->db()->select1($this->dd(
'shopProduct'),
'id = ' . $productId,
'title', 0) : array();
1901 $group = $groupId > 0 ? $this->db()->select1($this->dd(
'shopProductGroup'),
'id = ' . $groupId,
'title', 0) : array();
1902 $row[
'product_title'] = is_array($product) ? (string)($product[
'title'] ??
'') :
'';
1903 $row[
'group_title'] = is_array($group) ? (string)($group[
'title'] ??
'') :
'';
1911 $imageId = max(0, $imageId);
1912 $mediaId = max(0, $mediaId);
1913 if ($imageId <= 0 || $mediaId <= 0) {
1917 $imagePath = trim(str_replace(
'\\',
'/', $imagePath));
1918 if ($imagePath ===
'') {
1919 $imagePath =
'dbxmedia:' . $mediaId;
1922 $this->db()->update(
1923 $this->dd(
'shopProductImage'),
1924 array(
'media_id' => $mediaId,
'image_path' => $imagePath),
1925 'id = ' . (
int)$imageId,
1930 public function saveImage(
int $productId,
int $groupId,
string $imagePath,
string $title,
string $alt,
int $isPrimary = 0,
int $sorter = 100): void {
1932 $productId = max(0, $productId);
1933 $groupId = max(0, $groupId);
1934 $imagePath = trim(str_replace(
'\\',
'/', $imagePath));
1935 if ($imagePath ===
'' || ($productId <= 0 && $groupId <= 0)) {
1938 if ($groupId > 0 && $productId <= 0) {
1939 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1),
'group_id = ' . (
int)$groupId .
' AND product_id = 0 AND trash = 0', 0);
1943 $this->dd(
'shopProductImage'),
1945 'product_id' => $productId,
1946 'group_id' => $groupId,
1947 'image_path' => $imagePath,
1950 'is_primary' => $isPrimary,
1952 'sorter' => $sorter,
1954 'product_id = ' . (
int)$productId .
' AND group_id = ' . (
int)$groupId .
' AND image_path = ' . $this->sqlValue($imagePath),
1959 public function saveMediaImage(
int $productId,
int $groupId,
int $mediaId,
string $title =
'',
string $alt =
'',
int $isPrimary = 0,
int $sorter = 100): ?array {
1961 $productId = max(0, $productId);
1962 $groupId = max(0, $groupId);
1963 $mediaId = max(0, $mediaId);
1964 if ($mediaId <= 0 || ($productId <= 0 && $groupId <= 0)) {
1967 if ($groupId > 0 && $productId <= 0) {
1968 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1),
'group_id = ' . (
int)$groupId .
' AND product_id = 0 AND trash = 0', 0);
1972 $pathKey =
'dbxmedia:' . $mediaId;
1974 $this->dd(
'shopProductImage'),
1976 'product_id' => $productId,
1977 'group_id' => $groupId,
1978 'media_id' => $mediaId,
1979 'image_path' => $pathKey,
1982 'is_primary' => $isPrimary,
1984 'sorter' => $sorter,
1986 'product_id = ' . (
int)$productId .
' AND group_id = ' . (
int)$groupId .
' AND image_path = ' . $this->sqlValue($pathKey),
1990 $data = $this->db()->select1(
1991 $this->dd(
'shopProductImage'),
1992 'product_id = ' . (
int)$productId .
' AND group_id = ' . (
int)$groupId .
' AND image_path = ' . $this->sqlValue($pathKey),
1996 return is_array($data) ? $data :
null;
2001 if ($groupId <= 0) {
2004 $rows = $this->db()->select(
2005 $this->dd(
'shopProductImage'),
2006 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . (
int)$groupId,
2008 'is_primary DESC, sorter ASC, title ASC',
2015 $row = is_array($rows) && isset($rows[0]) ? $rows[0] : array();
2016 return is_array($row) ? $row :
null;
2021 $imageId = max(0, $imageId);
2022 $productId = max(0, $productId);
2023 if ($imageId <= 0) {
2027 $where =
'id = ' . (int)$imageId;
2028 if ($productId > 0) {
2029 $groupMaps = $this->db()->select($this->dd(
'shopProductGroupMap'),
'product_id = ' . (
int)$productId,
'group_id',
'',
'ASC',
'', 0, 0, 0);
2030 $groupIds = array();
2031 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
2032 $groupId = (int)($groupMap[
'group_id'] ?? 0);
2033 if ($groupId > 0) $groupIds[$groupId] = $groupId;
2035 $parts = array(
'product_id = ' . (
int)$productId);
2036 if ($groupIds !== array()) {
2037 $parts[] =
'group_id IN (' . implode(
',', array_map(
'intval', $groupIds)) .
')';
2039 $where .=
' AND (' . implode(
' OR ', $parts) .
')';
2042 if ($this->db()->count($this->dd(
'shopProductImage'), $where) <= 0) {
2045 $this->db()->update($this->dd(
'shopProductImage'), array(
'active' => 0,
'trash' => 1), $where, 0);
2049 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 {
2051 if ($items === array()) {
2055 $now = date(
'Y-m-d H:i:s');
2056 $orderNo =
'S' . date(
'YmdHis') .
'-' . random_int(1000, 9999);
2057 $uid = function_exists(
'dbx') ? (int)
dbx()->user() : 0;
2058 $allowedStatus = array(
'new',
'payment_pending',
'paid',
'processing',
'shipped',
'done',
'cancelled');
2059 $allowedPayment = array(
'open',
'created',
'pending',
'completed',
'paid',
'failed',
'cancelled',
'refunded');
2060 if (!in_array($status, $allowedStatus,
true)) {
2061 $status =
'payment_pending';
2063 if (!in_array($paymentStatus, $allowedPayment,
true)) {
2064 $paymentStatus =
'open';
2067 $snapshots = array();
2069 foreach ($items as $sku => $qty) {
2070 $qty = max(1, (
int)$qty);
2071 $product = $this->productBySku((
string)$sku);
2072 if (!$product)
continue;
2073 $price = (float)($product[
'price_gross'] ?? 0);
2074 $shipping = (float)($product[
'effective_shipping_gross'] ?? 0);
2075 $line = ($price + $shipping) * $qty;
2077 $snapshots[] = array(
2078 'product_id' => (
int)($product[
'id'] ?? 0),
2079 'sku' => (
string)($product[
'sku'] ?? $sku),
2080 'title' => (
string)($product[
'title'] ??
''),
2082 'price_gross' => $price,
2083 'tax_rate' => (
float)($product[
'effective_tax_rate'] ?? 0),
2084 'shipping_gross' => $shipping,
2085 'total_gross' => $line,
2089 if ($snapshots === array()) {
2092 $stockIssues = $this->stockIssuesForItems($items);
2093 if ($stockIssues !== array()) {
2094 $first = $stockIssues[0];
2095 throw new \RuntimeException(
'Nicht genuegend Lagerbestand fuer ' . (
string)($first[
'title'] ?? $first[
'sku'] ??
'Artikel') .
'.');
2097 $stockReserved = $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2099 $orderOk = (int)$this->db()->insert($this->dd(
'shopOrder'), array(
2100 'create_date' => $now,
2101 'update_date' => $now,
2102 'order_no' => $orderNo,
2104 'status' => $status,
2105 'customer_name' => $customerName,
2106 'customer_email' => $customerEmail,
2107 'customer_phone' => $customerPhone,
2108 'shipping_address' => $shippingAddress,
2109 'total_gross' => $total,
2110 'currency' =>
'EUR',
2111 'channel_key' => $channelKey,
2112 'payment_provider' => $paymentProvider,
2113 'payment_status' => $paymentStatus,
2114 'stock_reserved' => $stockReserved,
2115 'legal_snapshot' => $legalSnapshot,
2116 'withdrawal_snapshot' => $withdrawalSnapshot,
2119 if ($orderOk !== 1) {
2122 $orderId = (int)$this->db()->get_insert_id();
2123 if ($orderId <= 0) {
2127 foreach ($snapshots as $item) {
2128 $this->db()->insert($this->dd(
'shopOrderItem'), array(
2129 'create_date' => $now,
2130 'update_date' => $now,
2131 'order_id' => $orderId,
2132 'product_id' => $item[
'product_id'],
2133 'sku' => $item[
'sku'],
2134 'title' => $item[
'title'],
2135 'qty' => $item[
'qty'],
2136 'price_gross' => $item[
'price_gross'],
2137 'tax_rate' => $item[
'tax_rate'],
2138 'shipping_gross' => $item[
'shipping_gross'],
2139 'total_gross' => $item[
'total_gross'],
2142 $reserved = $this->reserveStockForSnapshots($snapshots);
2143 if ($stockReserved === 1 && $reserved <= 0) {
2144 $this->db()->update($this->dd(
'shopOrder'), array(
'stock_reserved' => 0),
'id = ' . $orderId .
' AND trash = 0', 0);
2146 $this->addOrderHistory($orderId,
'created',
'', $status,
'Bestellung wurde angelegt.');
2148 return $this->orderByNo($orderNo);
2154 if (!$channel || (
int)($channel[
'order_import_enabled'] ?? 0) !== 1 || (
int)($channel[
'active'] ?? 0) !== 1) {
2155 throw new \RuntimeException(
'Order-Import fuer diesen Channel ist nicht aktiv.');
2158 $externalId = trim((
string)($payload[
'order_id'] ?? $payload[
'external_order_id'] ?? $payload[
'id'] ??
''));
2159 if ($externalId ===
'') {
2160 throw new \RuntimeException(
'Payload enthaelt keine externe Bestellnummer.');
2163 $paymentStatus = strtolower((
string)($payload[
'payment_status'] ?? $payload[
'status'] ??
'completed'));
2164 $normalizedPayment = in_array($paymentStatus, array(
'paid',
'completed',
'captured'),
true)
2166 : (in_array($paymentStatus, array(
'cancelled',
'canceled',
'voided'),
true) ?
'cancelled' :
'pending');
2168 $existing = $this->orderByPaymentReference($channelKey, $externalId);
2170 $this->updateOrderPayment((
int)$existing[
'id'], $channelKey, $normalizedPayment, $externalId, $payload);
2171 return $this->orderByNo((
string)$existing[
'order_no']);
2174 $items = is_array($payload[
'items'] ??
null) ? $payload[
'items'] : array();
2175 if ($items === array()) {
2176 throw new \RuntimeException(
'Payload enthaelt keine Positionen.');
2179 $now = date(
'Y-m-d H:i:s');
2180 $orderNo =
'C' . date(
'YmdHis') .
'-' . random_int(1000, 9999);
2181 $customer = is_array($payload[
'customer'] ??
null) ? $payload[
'customer'] : array();
2182 $customerName = (string)($payload[
'customer_name'] ?? $customer[
'name'] ??
'');
2183 $customerEmail = (string)($payload[
'customer_email'] ?? $customer[
'email'] ??
'');
2184 $customerPhone = (string)($payload[
'customer_phone'] ?? $customer[
'phone'] ??
'');
2185 $shipping = is_array($payload[
'shipping_address'] ??
null) ? $payload[
'shipping_address'] : (is_array($payload[
'shipping'] ??
null) ? $payload[
'shipping'] : array());
2186 $shippingAddress = trim((
string)($payload[
'shipping_address_text'] ?? $payload[
'address'] ??
''));
2187 if ($shippingAddress ===
'' && $shipping !== array()) {
2188 $shippingAddress = trim(implode(
"\n", array_filter(array_map(
'strval', array(
2189 $shipping[
'name'] ?? $customerName,
2190 $shipping[
'street'] ?? $shipping[
'address1'] ??
'',
2191 $shipping[
'address2'] ??
'',
2192 trim((
string)($shipping[
'zip'] ?? $shipping[
'postal_code'] ??
'') .
' ' . (
string)($shipping[
'city'] ??
'')),
2193 $shipping[
'country'] ?? $shipping[
'country_code'] ??
'',
2196 $currency = (string)($payload[
'currency'] ??
'EUR');
2197 $snapshots = array();
2200 foreach ($items as $item) {
2201 if (!is_array($item)) {
2204 $sku = (string)($item[
'sku'] ?? $item[
'seller_sku'] ?? $item[
'item_sku'] ??
'');
2205 $qty = max(1, (
int)($item[
'qty'] ?? $item[
'quantity'] ?? 1));
2206 $product = $sku !==
'' ? $this->productBySku($sku,
false) : null;
2207 $price = (float)($item[
'price_gross'] ?? $item[
'price'] ?? $item[
'unit_price'] ?? ($product[
'price_gross'] ?? 0));
2208 $shipping = (float)($item[
'shipping_gross'] ?? $item[
'shipping'] ?? 0);
2209 $lineTotal = (float)($item[
'total_gross'] ?? $item[
'total'] ?? (($price + $shipping) * $qty));
2210 $total += $lineTotal;
2211 $snapshots[] = array(
2212 'product_id' => (
int)($product[
'id'] ?? 0),
2214 'title' => (
string)($item[
'title'] ?? $item[
'name'] ?? $product[
'title'] ?? $sku),
2216 'price_gross' => $price,
2217 'tax_rate' => (
float)($item[
'tax_rate'] ?? $product[
'effective_tax_rate'] ?? 0),
2218 'shipping_gross' => $shipping,
2219 'total_gross' => $lineTotal,
2223 if ($snapshots === array()) {
2224 throw new \RuntimeException(
'Payload enthaelt keine verwertbaren Positionen.');
2226 if (isset($payload[
'total_gross']) || isset($payload[
'total']) || isset($payload[
'amount'])) {
2227 $total = (float)($payload[
'total_gross'] ?? $payload[
'total'] ?? $payload[
'amount']);
2229 $stockReserved = $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2231 $orderOk = (int)$this->db()->insert($this->dd(
'shopOrder'), array(
2232 'create_date' => $now,
2233 'update_date' => $now,
2234 'order_no' => $orderNo,
2236 'status' => $normalizedPayment ===
'completed' ?
'paid' : ($normalizedPayment ===
'cancelled' ?
'cancelled' :
'payment_pending'),
2237 'customer_name' => $customerName,
2238 'customer_email' => $customerEmail,
2239 'customer_phone' => $customerPhone,
2240 'shipping_address' => $shippingAddress,
2241 'total_gross' => $total,
2242 'currency' => $currency,
2243 'channel_key' => $channelKey,
2244 'payment_provider' => $channelKey,
2245 'payment_status' => $normalizedPayment,
2246 'stock_reserved' => $stockReserved,
2247 'payment_reference' => $externalId,
2248 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
2250 if ($orderOk !== 1) {
2253 $orderId = (int)$this->db()->get_insert_id();
2254 if ($orderId <= 0) {
2258 foreach ($snapshots as $item) {
2259 $this->db()->insert($this->dd(
'shopOrderItem'), array(
2260 'create_date' => $now,
2261 'update_date' => $now,
2262 'order_id' => $orderId,
2263 'product_id' => $item[
'product_id'],
2264 'sku' => $item[
'sku'],
2265 'title' => $item[
'title'],
2266 'qty' => $item[
'qty'],
2267 'price_gross' => $item[
'price_gross'],
2268 'tax_rate' => $item[
'tax_rate'],
2269 'shipping_gross' => $item[
'shipping_gross'],
2270 'total_gross' => $item[
'total_gross'],
2273 $reserved = $this->reserveStockForSnapshots($snapshots);
2274 if ($stockReserved === 1 && $reserved <= 0) {
2275 $this->db()->update($this->dd(
'shopOrder'), array(
'stock_reserved' => 0),
'id = ' . $orderId .
' AND trash = 0', 0);
2277 $this->addOrderHistory($orderId,
'channel_import',
'', $normalizedPayment,
'Bestellung wurde ueber Channel ' . $channelKey .
' importiert.');
2279 return $this->orderByNo($orderNo);
2284 $row = $this->db()->select1($this->dd(
'shopOrder'),
'order_no = ' . $this->sqlValue($orderNo) .
' AND trash = 0',
'*', 0);
2285 if (!is_array($row) || (
int)($row[
'id'] ?? 0) <= 0)
return null;
2286 $row[
'items'] = $this->
orderItems((
int)$row[
'id']);
2287 $row[
'history'] = $this->
orderHistory((
int)$row[
'id']);
2294 $row = $this->db()->select1($this->dd(
'shopOrder'),
'id = ' . (
int)$id .
' AND trash = 0',
'*', 0);
2295 if (!is_array($row) || (
int)($row[
'id'] ?? 0) <= 0)
return null;
2296 $row[
'items'] = $this->
orderItems((
int)$row[
'id']);
2297 $row[
'history'] = $this->
orderHistory((
int)$row[
'id']);
2307 $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);
2308 $rows = is_array($rows) ? $rows : array();
2309 foreach ($rows as &$row) {
2310 $orderId = (int)($row[
'id'] ?? 0);
2311 $row[
'items'] = $this->orderItems($orderId);
2312 $row[
'history'] = $this->orderHistory($orderId);
2313 $row[
'withdrawals'] = $this->withdrawalsForOrder($orderId);
2319 public function orders(array $filters = array(),
int $limit = 50,
int $offset = 0,
string $sort =
'create_date',
string $direction =
'DESC'): array {
2321 $where = array(
'trash = 0');
2323 $query = trim((
string)($filters[
'query'] ??
''));
2324 if ($query !==
'') {
2325 $like = $this->sqlLikeValue($query);
2326 $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 .
')';
2328 foreach (array(
'status',
'payment_status',
'shipping_status',
'channel_key') as
$field) {
2329 $value = trim((
string)($filters[
$field] ??
''));
2330 if ($value !==
'') {
2331 $where[] =
$field .
' = ' . $this->sqlValue($value);
2335 $allowedSort = array(
'create_date',
'order_no',
'status',
'payment_status',
'shipping_status',
'customer_name',
'total_gross',
'channel_key');
2336 if (!in_array($sort, $allowedSort,
true)) {
2337 $sort =
'create_date';
2339 $direction = strtoupper($direction) ===
'ASC' ?
'ASC' :
'DESC';
2340 $max = $limit > 0 ? max(1, $limit) : 0;
2341 $rows = $this->db()->select($this->dd(
'shopOrder'), implode(
' AND ', $where),
'*', $sort .
' ' . $direction .
', id DESC',
'ASC',
'', $max, max(0, $offset), 0);
2342 $rows = is_array($rows) ? $rows : array();
2343 foreach ($rows as &$row) {
2344 $row[
'items'] = $this->orderItems((
int)$row[
'id']);
2352 $where = array(
'trash = 0');
2353 $query = trim((
string)($filters[
'query'] ??
''));
2354 if ($query !==
'') {
2355 $like = $this->sqlLikeValue($query);
2356 $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 .
')';
2358 foreach (array(
'status',
'payment_status',
'shipping_status',
'channel_key') as
$field) {
2359 $value = trim((
string)($filters[
$field] ??
''));
2360 if ($value !==
'') {
2361 $where[] =
$field .
' = ' . $this->sqlValue($value);
2364 return max(0, (
int)$this->db()->count($this->dd(
'shopOrder'), implode(
' AND ', $where)));
2369 $rows = $this->db()->select($this->dd(
'shopOrder'),
"trash = 0 AND channel_key <> ''",
'channel_key',
'channel_key ASC',
'ASC',
'', 0, 0, 0);
2371 foreach (is_array($rows) ? $rows : array() as $row) {
2372 $key = (string)($row[
'channel_key'] ??
'');
2378 return array_values($keys);
2383 $allowedStatus = array(
'new',
'payment_pending',
'paid',
'processing',
'shipped',
'done',
'cancelled');
2384 $allowedPayment = array(
'open',
'created',
'pending',
'completed',
'paid',
'failed',
'cancelled',
'refunded');
2385 $allowedShipping = array(
'open',
'ready',
'shipped',
'delivered',
'returned');
2387 $status = (string)($data[
'status'] ??
'new');
2388 $paymentStatus = (string)($data[
'payment_status'] ??
'open');
2389 $shippingStatus = (string)($data[
'shipping_status'] ?? ($before[
'shipping_status'] ??
'open'));
2390 if (!in_array($status, $allowedStatus,
true)) $status =
'new';
2391 if (!in_array($paymentStatus, $allowedPayment,
true)) $paymentStatus =
'open';
2392 if (!in_array($shippingStatus, $allowedShipping,
true)) $shippingStatus =
'open';
2393 $invoiceNo = trim((
string)($data[
'invoice_no'] ?? ($before[
'invoice_no'] ??
'')));
2394 $invoiceDate = trim((
string)($data[
'invoice_date'] ?? ($before[
'invoice_date'] ??
'')));
2395 if ($invoiceNo ===
'' && in_array($status, array(
'paid',
'processing',
'shipped',
'done'),
true)) {
2397 $invoiceDate = date(
'Y-m-d');
2399 $shippedDate = trim((
string)($data[
'shipped_date'] ?? ($before[
'shipped_date'] ??
'')));
2400 if ($shippingStatus ===
'shipped' && $shippedDate ===
'') {
2401 $shippedDate = date(
'Y-m-d H:i:s');
2403 $shippingProvider = trim((
string)($data[
'shipping_provider'] ??
''));
2404 $trackingNo = trim((
string)($data[
'tracking_no'] ??
''));
2405 $trackingUrl = trim((
string)($data[
'tracking_url'] ??
''));
2406 if ($trackingUrl ===
'' && $trackingNo !==
'') {
2407 $trackingUrl = $this->trackingUrlForProvider($shippingProvider, $trackingNo);
2410 $ok = $this->db()->update($this->dd(
'shopOrder'), array(
2411 'update_date' => date(
'Y-m-d H:i:s'),
2412 'status' => $status,
2413 'payment_status' => $paymentStatus,
2414 'payment_reference' => (
string)($data[
'payment_reference'] ??
''),
2415 'invoice_no' => $invoiceNo,
2416 'invoice_date' => $invoiceDate,
2417 'shipping_status' => $shippingStatus,
2418 'shipping_provider' => $shippingProvider,
2419 'tracking_no' => $trackingNo,
2420 'tracking_url' => $trackingUrl,
2421 'shipped_date' => $shippedDate,
2422 'note' => (
string)($data[
'note'] ??
''),
2423 ),
'id = ' . (
int)$id .
' AND trash = 0', 0);
2424 if (is_array($before)) {
2426 'status' => $status,
2427 'payment_status' => $paymentStatus,
2428 'shipping_status' => $shippingStatus,
2429 'invoice_no' => $invoiceNo,
2430 'tracking_no' => $trackingNo,
2431 ) as
$field => $newValue) {
2432 $oldValue = (string)($before[
$field] ??
'');
2433 if ($oldValue !== (
string)$newValue) {
2434 $this->addOrderHistory($id,
$field, $oldValue, (
string)$newValue,
'Admin-Aenderung');
2437 if ($status ===
'cancelled' || in_array($paymentStatus, array(
'cancelled',
'refunded'),
true) || $shippingStatus ===
'returned') {
2438 $fresh = $this->orderById($id);
2439 if (is_array($fresh)) {
2440 $this->releaseStockForOrder($fresh,
'Bestand wurde durch Statusaenderung zurueckgebucht.');
2444 return $ok !== 0 || $this->orderById($id) !==
null;
2447 private function trackingUrlForProvider(
string $provider,
string $trackingNo): string {
2448 $trackingNo = trim($trackingNo);
2449 if ($trackingNo ===
'') {
2452 $providerKey = strtolower(trim($provider));
2453 if (str_contains($providerKey,
'dhl')) {
2454 return 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=' . rawurlencode($trackingNo);
2456 if (str_contains($providerKey,
'ups')) {
2457 return 'https://www.ups.com/track?tracknum=' . rawurlencode($trackingNo);
2459 if (str_contains($providerKey,
'dpd')) {
2460 return 'https://tracking.dpd.de/status/de_DE/parcel/' . rawurlencode($trackingNo);
2462 if (str_contains($providerKey,
'hermes')) {
2463 return 'https://www.myhermes.de/empfangen/sendungsverfolgung/?su=' . rawurlencode($trackingNo);
2471 if (!is_array($order)) {
2472 return array(
false,
'Bestellung nicht gefunden.');
2479 $data[
'payment_status'] =
'paid';
2480 if (in_array((
string)($data[
'status'] ??
''), array(
'new',
'payment_pending'),
true)) {
2481 $data[
'status'] =
'paid';
2483 $message =
'Bestellung wurde als bezahlt markiert.';
2487 $data[
'status'] =
'processing';
2488 $message =
'Bestellung wurde in Bearbeitung gesetzt.';
2492 $data[
'shipping_status'] =
'ready';
2493 if (in_array((
string)($data[
'status'] ??
''), array(
'new',
'payment_pending',
'paid'),
true)) {
2494 $data[
'status'] =
'processing';
2496 $message =
'Bestellung wurde als versandbereit markiert.';
2500 $data[
'shipping_status'] =
'shipped';
2501 $data[
'status'] =
'shipped';
2502 if (trim((
string)($data[
'shipped_date'] ??
'')) ===
'') {
2503 $data[
'shipped_date'] = date(
'Y-m-d H:i:s');
2505 $message =
'Bestellung wurde als versendet markiert.';
2509 $data[
'shipping_status'] =
'delivered';
2510 $data[
'status'] =
'done';
2511 $message =
'Bestellung wurde als zugestellt und abgeschlossen markiert.';
2515 $data[
'status'] =
'cancelled';
2516 if (!in_array((
string)($data[
'payment_status'] ??
''), array(
'completed',
'paid',
'refunded'),
true)) {
2517 $data[
'payment_status'] =
'cancelled';
2519 $message =
'Bestellung wurde storniert.';
2523 $data[
'payment_status'] =
'refunded';
2524 $data[
'status'] =
'cancelled';
2525 if ((
string)($data[
'shipping_status'] ??
'') !==
'delivered') {
2526 $data[
'shipping_status'] =
'returned';
2528 $message =
'Bestellung wurde als erstattet markiert.';
2532 return array(
false,
'Unbekannte Bestellaktion.');
2535 $ok = $this->updateOrderAdmin($id, $data);
2537 $this->addOrderHistory($id,
'quick_action',
'', $action, $message);
2538 return array(
true, $message);
2540 return array(
false,
'Bestellaktion konnte nicht gespeichert werden.');
2545 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;
2550 $row = $this->db()->select1($this->dd(
'shopOrder'),
'payment_provider = ' . $this->sqlValue($provider) .
' AND payment_reference = ' . $this->sqlValue($reference) .
' AND trash = 0',
'*', 0);
2551 if (!is_array($row) || (
int)($row[
'id'] ?? 0) <= 0)
return null;
2552 $row[
'items'] = $this->
orderItems((
int)$row[
'id']);
2557 $rows = $this->db()->select($this->dd(
'shopOrderItem'),
'order_id = ' . (int)$orderId .
' AND trash = 0',
'*',
'id ASC',
'ASC',
'', 0, 0, 0);
2558 return is_array($rows) ? $rows : array();
2561 public function updateOrderPayment(
int $orderId,
string $provider,
string $status,
string $reference =
'', array $payload = array()): void {
2563 $orderStatus = $status ===
'completed' ?
'paid' : ($status ===
'cancelled' ?
'cancelled' :
'payment_pending');
2564 $this->db()->update($this->dd(
'shopOrder'), array(
2565 'update_date' => date(
'Y-m-d H:i:s'),
2566 'payment_provider' => $provider,
2567 'payment_status' => $status,
2568 'payment_reference' => $reference,
2569 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
2570 'status' => $orderStatus,
2571 ),
'id = ' . (
int)$orderId, 0);
2572 $this->
addOrderHistory($orderId,
'payment',
'', $status,
'Zahlungsstatus wurde aktualisiert.');
2573 if (in_array($status, array(
'cancelled',
'refunded'),
true) || $orderStatus ===
'cancelled') {
2575 if (is_array($fresh)) {
2576 $this->releaseStockForOrder($fresh,
'Bestand wurde durch Zahlungsstatus zurueckgebucht.');
2581 public function addOrderHistory(
int $orderId,
string $eventType,
string $oldValue =
'',
string $newValue =
'',
string $message =
''): void {
2582 if ($orderId <= 0) {
2585 $order = $this->db()->select1($this->dd(
'shopOrder'),
'id = ' . (
int)$orderId .
' AND trash = 0',
'owner,uid', 0);
2586 $owner = is_array($order) ? (int)($order[
'owner'] ?? 0) : 0;
2587 if ($owner <= 0 && is_array($order)) {
2588 $owner = (int)($order[
'uid'] ?? 0);
2591 'order_id' => $orderId,
2592 'event_type' => $eventType,
2593 'old_value' => $oldValue,
2594 'new_value' => $newValue,
2595 'message' => $message,
2598 $data[
'owner'] = $owner;
2600 $this->db()->insert($this->dd(
'shopOrderHistory'), $data, 0);
2604 $rows = $this->db()->select($this->dd(
'shopOrderHistory'),
'order_id = ' . (int)$orderId .
' AND trash = 0',
'*',
'create_date DESC, id DESC',
'ASC',
'', 0, 0, 0);
2605 return is_array($rows) ? $rows : array();
2609 $prefix =
'R' . date(
'Y');
2610 $rows = $this->db()->select($this->dd(
'shopOrder'),
'invoice_no LIKE ' . $this->sqlValue($prefix .
'%'),
'invoice_no',
'invoice_no DESC',
'DESC',
'', 1, 0, 0);
2611 $last = is_array($rows) && isset($rows[0][
'invoice_no']) ? (string)$rows[0][
'invoice_no'] :
'';
2613 if (preg_match(
'/(\d+)$/', $last, $m)) {
2614 $next = ((int)$m[1]) + 1;
2616 return $prefix .
'-' . str_pad((
string)$next, 5,
'0', STR_PAD_LEFT);
2619 private function invoiceArchiveDir(
string $year): string {
2620 $base = function_exists(
'dbx_get_base_dir') ? rtrim((string)
dbx_get_base_dir(),
'/\\') : dirname(__DIR__, 4);
2621 return $base .
'/files/shop/invoices/' . $year;
2624 private function invoiceArchiveRelPath(
string $year,
string $fileName): string {
2625 return
'files/shop/invoices/' . $year .
'/' . $fileName;
2628 private function pdfText(
string $text): string {
2629 $text = str_replace(array(
"\r\n",
"\r"),
"\n", $text);
2630 $text = strtr($text, array(
2638 $converted = function_exists(
'iconv') ? @iconv(
'UTF-8',
'Windows-1252//TRANSLIT', $text) : false;
2639 if ($converted !==
false) {
2642 return str_replace(array(
'\\',
'(',
')'), array(
'\\\\',
'\\(',
'\\)'), $text);
2645 private function createSimpleInvoicePdf(array $order,
string $absFile): bool {
2646 $invoiceNo = trim((string)($order[
'invoice_no'] ??
''));
2647 $invoiceDate = trim((
string)($order[
'invoice_date'] ?? date(
'Y-m-d')));
2649 'Rechnung ' . $invoiceNo,
2650 'Datum: ' . $invoiceDate,
2651 'Bestellung: ' . (
string)($order[
'order_no'] ??
''),
2654 (
string)($order[
'customer_name'] ??
''),
2655 (
string)($order[
'customer_email'] ??
''),
2657 $address = trim((
string)($order[
'shipping_address'] ??
''));
2658 if ($address !==
'') {
2660 $lines[] =
'Lieferadresse:';
2661 foreach (explode(
"\n", str_replace(
"\r",
'', $address)) as $line) {
2666 $lines[] =
'Positionen:';
2667 foreach ((array)($order[
'items'] ?? array()) as $item) {
2668 $title = trim((
string)($item[
'title'] ??
'Artikel'));
2669 $sku = trim((
string)($item[
'sku'] ??
''));
2670 $qty = (int)($item[
'qty'] ?? 0);
2671 $total = number_format((
float)($item[
'total_gross'] ?? 0), 2,
',',
'.') .
' EUR';
2672 $tax = number_format((
float)($item[
'tax_rate'] ?? 0), 2,
',',
'.') .
' % MwSt.';
2673 $lines[] = $qty .
' x ' . $title . ($sku !==
'' ?
' [' . $sku .
']' :
'') .
' - ' . $total .
' (' . $tax .
')';
2676 $lines[] =
'Gesamtbetrag: ' . number_format((
float)($order[
'total_gross'] ?? 0), 2,
',',
'.') .
' EUR';
2678 $lines[] =
'Dieser Beleg wurde aus dem gespeicherten Bestell-Snapshot erzeugt.';
2680 $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";
2681 foreach ($lines as $line) {
2682 foreach (explode(
"\n", wordwrap((
string)$line, 95,
"\n",
true)) as $wrapped) {
2683 $content .=
'(' . $this->pdfText($wrapped) .
") Tj\n0 -14 Td\n";
2689 $objects[] =
"<< /Type /Catalog /Pages 2 0 R >>";
2690 $objects[] =
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
2691 $objects[] =
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>";
2692 $objects[] =
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
2693 $objects[] =
"<< /Length " . strlen($content) .
" >>\nstream\n" . $content .
"endstream";
2695 $pdf =
"%PDF-1.4\n";
2696 $offsets = array(0);
2697 foreach ($objects as $i => $object) {
2698 $offsets[$i + 1] = strlen($pdf);
2699 $pdf .= ($i + 1) .
" 0 obj\n" . $object .
"\nendobj\n";
2701 $xref = strlen($pdf);
2702 $pdf .=
"xref\n0 " . (count($objects) + 1) .
"\n";
2703 $pdf .=
"0000000000 65535 f \n";
2704 for ($i = 1; $i <= count($objects); $i++) {
2705 $pdf .= str_pad((
string)$offsets[$i], 10,
'0', STR_PAD_LEFT) .
" 00000 n \n";
2707 $pdf .=
"trailer\n<< /Size " . (count($objects) + 1) .
" /Root 1 0 R >>\nstartxref\n" . $xref .
"\n%%EOF";
2709 $dir = dirname($absFile);
2710 if (!is_dir(
$dir) && !@mkdir(
$dir, 0775,
true)) {
2713 return @file_put_contents($absFile, $pdf) !==
false;
2719 if (!is_array($order)) {
2723 $invoiceNo = trim((
string)($order[
'invoice_no'] ??
''));
2724 $invoiceDate = trim((
string)($order[
'invoice_date'] ??
''));
2726 if ($invoiceNo ===
'') {
2727 $invoiceNo = $this->nextInvoiceNo();
2728 $updates[
'invoice_no'] = $invoiceNo;
2730 if ($invoiceDate ===
'') {
2731 $invoiceDate = date(
'Y-m-d');
2732 $updates[
'invoice_date'] = $invoiceDate;
2734 if ($updates !== array()) {
2735 $updates[
'update_date'] = date(
'Y-m-d H:i:s');
2736 $this->db()->update($this->dd(
'shopOrder'), $updates,
'id = ' . (
int)$id .
' AND trash = 0', 0);
2737 $order = $this->orderById($id) ?: $order;
2740 $year = substr($invoiceDate, 0, 4);
2741 if (!preg_match(
'/^\d{4}$/', $year)) {
2744 $safeNo = preg_replace(
'/[^A-Za-z0-9_.-]+/',
'-', $invoiceNo) ?: (
'rechnung-' . $id);
2745 $fileName = $safeNo .
'.pdf';
2746 $relPath = $this->invoiceArchiveRelPath($year, $fileName);
2747 $absPath = $this->invoiceArchiveDir($year) .
'/' . $fileName;
2748 $oldRelPath = trim((
string)($order[
'invoice_pdf_path'] ??
''));
2751 if (!is_file($absPath)) {
2752 if (!$this->createSimpleInvoicePdf($order, $absPath)) {
2758 $this->db()->update($this->dd(
'shopOrder'), array(
2759 'invoice_pdf_path' => $relPath,
2760 'invoice_pdf_date' => date(
'Y-m-d H:i:s'),
2761 'update_date' => date(
'Y-m-d H:i:s'),
2762 ),
'id = ' . (
int)$id .
' AND trash = 0', 0);
2763 if (
$created || $oldRelPath !== $relPath) {
2764 $this->addOrderHistory($id,
'invoice_pdf',
'', $relPath,
'Rechnungs-PDF wurde erzeugt oder aktualisiert.');
2766 return $this->orderById($id);
2770 $rel = trim((string)($order[
'invoice_pdf_path'] ??
''));
2774 $base = function_exists(
'dbx_get_base_dir') ? rtrim((
string)
dbx_get_base_dir(),
'/\\') : dirname(__DIR__, 4);
2775 $path =
$base .
'/' . ltrim($rel,
'/\\');
2776 return is_file($path) ? $path :
'';
2781 $orderNo = trim((
string)($data[
'order_no'] ??
''));
2782 $order = $orderNo !==
'' ? $this->
orderByNo($orderNo) : null;
2783 $orderId = is_array($order) ? (int)($order[
'id'] ?? 0) : 0;
2784 $ok = (int)$this->db()->insert($this->dd(
'shopWithdrawal'), array(
2785 'order_id' => $orderId,
2786 'order_no' => $orderNo,
2787 'customer_name' => trim((
string)($data[
'customer_name'] ??
'')),
2788 'customer_email' => trim((
string)($data[
'customer_email'] ??
'')),
2789 'customer_address' => trim((
string)($data[
'customer_address'] ??
'')),
2790 'reason' => trim((
string)($data[
'reason'] ??
'')),
2796 $id = (int)$this->db()->get_insert_id();
2798 $this->addOrderHistory($orderId,
'withdrawal',
'',
'new',
'Widerruf wurde eingereicht.');
2800 return $this->withdrawalById($id);
2805 $allowed = array(
'new',
'processing',
'accepted',
'rejected',
'refunded',
'closed');
2806 if (!in_array($status, $allowed,
true)) {
2809 $before = $this->withdrawalById($id);
2810 if (!is_array($before)) {
2814 $ok = $this->db()->update($this->dd(
'shopWithdrawal'), array(
2815 'status' => $status,
2816 'admin_note' => $note !==
'' ? $note : (
string)($before[
'admin_note'] ??
''),
2817 'update_date' => date(
'Y-m-d H:i:s'),
2818 ),
'id = ' . (
int)$id .
' AND trash = 0', 0);
2820 $orderId = (int)($before[
'order_id'] ?? 0);
2822 $old = (string)($before[
'status'] ??
'');
2823 if ($old !== $status) {
2824 $this->addOrderHistory($orderId,
'withdrawal_status', $old, $status,
'Widerrufsstatus wurde geaendert.');
2826 if (in_array($status, array(
'accepted',
'refunded'),
true)) {
2827 $order = $this->orderById($orderId);
2828 if (is_array($order)) {
2829 $this->releaseStockForOrder($order,
'Bestand wurde durch Widerruf zurueckgebucht.');
2834 return $ok !== 0 || $this->withdrawalById($id) !==
null;
2838 $row = $this->db()->select1($this->dd(
'shopWithdrawal'),
'id = ' . (int)$id .
' AND trash = 0',
'*', 0);
2839 return is_array($row) ? $row :
null;
2842 public function withdrawals(array $filters = array(),
int $limit = 50,
int $offset = 0): array {
2844 $where = array(
'trash = 0');
2845 $query = trim((
string)($filters[
'query'] ??
''));
2846 if ($query !==
'') {
2847 $like = $this->sqlLikeValue($query);
2848 $where[] =
'(order_no LIKE ' . $like .
' OR customer_name LIKE ' . $like .
' OR customer_email LIKE ' . $like .
')';
2850 $status = trim((
string)($filters[
'status'] ??
''));
2851 if ($status !==
'') {
2852 $where[] =
'status = ' . $this->sqlValue($status);
2854 $rows = $this->db()->select($this->dd(
'shopWithdrawal'), implode(
' AND ', $where),
'*',
'create_date DESC, id DESC',
'ASC',
'', $limit > 0 ? $limit : 0, max(0, $offset), 0);
2855 return is_array($rows) ? $rows : array();
2859 $where = array(
'trash = 0');
2860 $query = trim((
string)($filters[
'query'] ??
''));
2861 if ($query !==
'') {
2862 $like = $this->sqlLikeValue($query);
2863 $where[] =
'(order_no LIKE ' . $like .
' OR customer_name LIKE ' . $like .
' OR customer_email LIKE ' . $like .
')';
2865 $status = trim((
string)($filters[
'status'] ??
''));
2866 if ($status !==
'') {
2867 $where[] =
'status = ' . $this->sqlValue($status);
2869 return max(0, (
int)$this->db()->count($this->dd(
'shopWithdrawal'), implode(
' AND ', $where)));
2873 if ($orderId <= 0) {
2876 $rows = $this->db()->select($this->dd(
'shopWithdrawal'),
'order_id = ' . (
int)$orderId .
' AND trash = 0',
'*',
'create_date DESC, id DESC',
'ASC',
'', 0, 0, 0);
2877 return is_array($rows) ? $rows : array();
2882 $ordersOpen = (int)$this->db()->count($this->dd(
'shopOrder'),
"trash = 0 AND status IN ('new','payment_pending','paid','processing')");
2883 $paymentsOpen = (int)$this->db()->count($this->dd(
'shopOrder'),
"trash = 0 AND payment_status IN ('open','created','pending')");
2884 $shipOpen = (int)$this->db()->count($this->dd(
'shopOrder'),
"trash = 0 AND shipping_status IN ('open','ready')");
2885 $withdrawalsOpen = (int)$this->db()->count($this->dd(
'shopWithdrawal'),
"trash = 0 AND status IN ('new','processing')");
2887 if ($this->stockEnabled()) {
2888 $stockLow = (int)$this->db()->count($this->dd(
'shopProduct'),
"trash = 0 AND active = 1 AND product_type = 'physical' AND stock <= 3");
2891 'orders_open' => $ordersOpen,
2892 'payments_open' => $paymentsOpen,
2893 'shipping_open' => $shipOpen,
2894 'withdrawals_open' => $withdrawalsOpen,
2895 'stock_low' => $stockLow,
2896 'products_active' => (
int)$this->db()->count($this->dd(
'shopProduct'),
'trash = 0 AND active = 1'),