dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxShopRepository.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxShop;
3
11 private array $requestCache = array();
12
13 private function remember(string $key, callable $loader): array {
14 if (!array_key_exists($key, $this->requestCache)) {
15 $value = $loader();
16 $this->requestCache[$key] = is_array($value) ? $value : array();
17 }
18 return $this->requestCache[$key];
19 }
20
21 private function clearRequestCache(): void {
22 $this->requestCache = array();
23 }
24
25 private function db() {
26 return dbx()->get_system_obj('dbxDB');
27 }
28
29 private function dd(string $name): string {
30 return 'dbxShop|' . $name;
31 }
32
33 private function sqlValue($value): string {
34 if ($value === null) {
35 return "''";
36 }
37 return "'" . str_replace("'", "''", (string)$value) . "'";
38 }
39
40 private function sqlLikeValue(string $value): string {
41 return $this->sqlValue('%' . $value . '%');
42 }
43
44 private function syncDdToDb(string $dd): bool {
45 $oDD = dbx()->get_system_obj('dbxDD');
46 $oDD->sync_dd_to_db('dbxShop', $dd, 'reset');
47 for ($i = 0; $i < 40; $i++) {
48 $state = $oDD->sync_dd_to_db('dbxShop', $dd, 'apply');
49 $status = (string)($state['status'] ?? '');
50 if ($status === 'finished') {
51 return true;
52 }
53 if (in_array($status, array('error', 'cancelled'), true)) {
54 return false;
55 }
56 }
57 return false;
58 }
59
60 private function syncShopSchemaFromDd(): bool {
61 static $done = false;
62 if ($done) {
63 return false;
64 }
65
66 $version = 'shop-dd-20260713-2';
67 if ((string)dbx()->get_config('dbxShop', 'schema_sync_version', '') === $version) {
68 $done = true;
69 return false;
70 }
71
72 foreach (array(
73 'shopProductGroup',
74 'shopChannel',
75 'shopProduct',
76 'shopProductImage',
77 'shopAttributeDefinition',
78 'shopProductAttributeValue',
79 'shopProductGroupMap',
80 'shopProductChannel',
81 'shopShippingGroup',
82 'shopProductShippingGroupMap',
83 'shopChannelGroup',
84 'shopChannelGroupChannel',
85 'shopProductChannelGroupMap',
86 'shopOrder',
87 'shopOrderItem',
88 'shopOrderHistory',
89 'shopWithdrawal',
90 ) as $dd) {
91 if (!$this->syncDdToDb($dd)) {
92 throw new \RuntimeException('dbxShop-DD konnte nicht mit der Datenbank synchronisiert werden: ' . $dd);
93 }
94 }
95
96 $cfg = dbx()->get_config('dbxShop', '', array());
97 $cfg = is_array($cfg) ? $cfg : array();
98 $cfg['schema_sync_version'] = $version;
99 dbx()->set_config('dbxShop', $cfg);
100 $done = true;
101 return true;
102 }
103
111 public function install(bool $maintenance = false): void {
112 static $maintenanceDone = false;
113 if (!$maintenance || $maintenanceDone) {
114 return;
115 }
116
117 $this->syncShopSchemaFromDd();
118 $this->syncChannelDefaults();
119 $this->syncPrimaryProductGroups();
120 $this->syncSingleGroupImages();
121 $this->clearRequestCache();
122 $maintenanceDone = true;
123 }
124
125 private function syncPrimaryProductGroups(): void {
126 $rows = $this->db()->select($this->dd('shopProduct'), 'trash = 0 AND (product_group_id IS NULL OR product_group_id <= 0)', 'id', '', 'ASC', '', 0, 0, 0);
127 foreach ((is_array($rows) ? $rows : array()) as $row) {
128 $productId = (int)($row['id'] ?? 0);
129 if ($productId <= 0) continue;
130 $maps = $this->db()->select($this->dd('shopProductGroupMap'), 'product_id = ' . $productId, '*', 'is_primary DESC', 'ASC', '', 0, 1, 0);
131 $map = is_array($maps) && isset($maps[0]) ? $maps[0] : array();
132 $groupId = (int)($map['group_id'] ?? 0);
133 if ($groupId > 0) {
134 $this->db()->update($this->dd('shopProduct'), array('product_group_id' => $groupId), 'id = ' . $productId, 0);
135 }
136 }
137 }
138
139 private function syncSingleGroupImages(): void {
140 $groups = $this->db()->select($this->dd('shopProductGroup'), 'trash = 0', 'id', '', 'ASC', '', 0, 0, 0);
141 foreach ((is_array($groups) ? $groups : array()) as $group) {
142 $groupId = (int)($group['id'] ?? 0);
143 if ($groupId <= 0) continue;
144 $rows = $this->db()->select(
145 $this->dd('shopProductImage'),
146 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . $groupId,
147 '*',
148 'is_primary DESC, sorter ASC, title ASC',
149 'ASC',
150 '',
151 0,
152 0,
153 0
154 );
155 $keep = 0;
156 foreach ((is_array($rows) ? $rows : array()) as $row) {
157 $id = (int)($row['id'] ?? 0);
158 if ($id <= 0) continue;
159 if ($keep <= 0) {
160 $keep = $id;
161 $this->db()->update($this->dd('shopProductImage'), array('is_primary' => 1, 'sorter' => 10), 'id = ' . $id, 0);
162 continue;
163 }
164 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), 'id = ' . $id, 0);
165 }
166 }
167 }
168
169 private function syncChannelDefaults(): void {
170 $channels = array(
171 array('shop', 'Shop', 'Eigener Shop ohne externe API.', 'shop', 'internal', 1, 0, '', '', '', '', 10),
172 array('amazon', 'Amazon', 'Amazon Marketplace Integration fuer Artikel-Export und spaeteren Order-Import.', 'amazon', 'api', 1, 1, 'https://sellingpartnerapi-eu.amazon.com', 'A1PA6795UKMFR9', 'ORDER_CHANGE', "Listings Items\nOrders\nNotifications", 20),
173 array('ebay', 'eBay', 'eBay Marketplace Integration fuer Listings und Bestellrueckmeldungen.', 'ebay', 'api', 1, 1, 'https://api.ebay.com', 'EBAY_DE', '', "https://api.ebay.com/oauth/api_scope/sell.inventory\nhttps://api.ebay.com/oauth/api_scope/sell.fulfillment\nhttps://api.ebay.com/oauth/api_scope/commerce.notification.subscription", 30),
174 array('kleinanzeigen', 'Kleinanzeigen', 'Kleinanzeigen ist als Channel vorbereitet. Eine allgemein frei nutzbare offizielle Anzeigen-/Order-API ist nicht hinterlegt; nutzen Sie hier nur vertraglich freigegebene Schnittstellen oder manuelle Pflege.', 'kleinanzeigen', 'manual', 1, 0, '', '', '', '', 40),
175 array('mobile', 'mobile.de', 'mobile.de Channel fuer Fahrzeug- oder Angebotsdaten ueber Seller API und Lead API.', 'mobile', 'api', 1, 1, 'https://services.mobile.de/seller-api', '', 'lead-api', "seller-api\nbasic-auth\nlead-api", 50),
176 );
177 foreach ($channels as $c) {
178 $existing = $this->db()->select1($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($c[0]), '*', 0);
179 $existing = is_array($existing) ? $existing : array();
180 $values = array(
181 'channel_key' => $c[0],
182 'title' => trim((string)($existing['title'] ?? '')) !== '' ? (string)$existing['title'] : $c[1],
183 'description' => trim((string)($existing['description'] ?? '')) !== '' ? (string)$existing['description'] : $c[2],
184 'platform_type' => trim((string)($existing['platform_type'] ?? '')) !== '' && (string)($existing['platform_type'] ?? '') !== 'custom' ? (string)$existing['platform_type'] : $c[3],
185 'connection_mode' => trim((string)($existing['connection_mode'] ?? '')) !== '' && (string)($existing['connection_mode'] ?? '') !== 'manual' ? (string)$existing['connection_mode'] : $c[4],
186 'export_enabled' => array_key_exists('export_enabled', $existing) ? (int)$existing['export_enabled'] : $c[5],
187 'order_import_enabled' => array_key_exists('order_import_enabled', $existing) ? (int)$existing['order_import_enabled'] : $c[6],
188 'api_base_url' => trim((string)($existing['api_base_url'] ?? '')) !== '' ? (string)$existing['api_base_url'] : $c[7],
189 'marketplace_id' => trim((string)($existing['marketplace_id'] ?? '')) !== '' ? (string)$existing['marketplace_id'] : $c[8],
190 'notification_topic' => trim((string)($existing['notification_topic'] ?? '')) !== '' ? (string)$existing['notification_topic'] : $c[9],
191 'api_scope' => trim((string)($existing['api_scope'] ?? '')) !== '' ? (string)$existing['api_scope'] : $c[10],
192 'active' => array_key_exists('active', $existing) ? (int)$existing['active'] : 1,
193 'sorter' => array_key_exists('sorter', $existing) ? (int)$existing['sorter'] : $c[11],
194 );
195 $this->db()->save($this->dd('shopChannel'), $values, 'channel_key = ' . $this->sqlValue($c[0]), 0);
196 }
197 $oldEbayScopes = 'https://api.ebay.com/oauth/api_scope/sell.inventory https://api.ebay.com/oauth/api_scope/sell.fulfillment https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
198 $newEbayScopes = "https://api.ebay.com/oauth/api_scope/sell.inventory\nhttps://api.ebay.com/oauth/api_scope/sell.fulfillment\nhttps://api.ebay.com/oauth/api_scope/commerce.notification.subscription";
199 $this->db()->update(
200 $this->dd('shopChannel'),
201 array('api_scope' => $newEbayScopes),
202 'channel_key = ' . $this->sqlValue('ebay') . ' AND api_scope = ' . $this->sqlValue($oldEbayScopes),
203 0
204 );
205 }
206
207 private function seedDemoProductsWithDbxDb(): void {
208 if ($this->db()->count($this->dd('shopProduct'), 'trash = 0') > 0) {
209 return;
210 }
211
212 $this->updateProductGroup(0, array(
213 'group_key' => 'software',
214 'title' => 'Software',
215 'description' => 'Digitale dbXapp Pakete und Erweiterungen.',
216 'tax_class' => 'mwst1',
217 'display_variant' => 'gallery_grid',
218 'card_template' => 'product-card-default',
219 'detail_template' => 'product-detail-default',
220 'gallery_template' => 'image-gallery',
221 'gallery_visible_count' => 3,
222 'gallery_image_size' => 'original',
223 'gallery_lightbox_width' => '100vw',
224 'gallery_overflow' => 'grid',
225 'gallery_click' => 'lightbox',
226 'attribute_notes' => 'Lizenz, Paketumfang, CMS-Funktionen und KI-Funktionen.',
227 'active' => 1,
228 'sorter' => 10,
229 ));
230 $this->updateProductGroup(0, array(
231 'group_key' => 'service',
232 'title' => 'Dienstleistungen',
233 'description' => 'Installation, Wartung und Schulung.',
234 'tax_class' => 'mwst1',
235 'display_variant' => 'gallery_slider',
236 'card_template' => 'product-card-default',
237 'detail_template' => 'product-detail-default',
238 'gallery_template' => 'image-gallery',
239 'gallery_visible_count' => 1,
240 'gallery_image_size' => 'original',
241 'gallery_lightbox_width' => '100vw',
242 'gallery_overflow' => 'slider',
243 'gallery_click' => 'lightbox',
244 'attribute_notes' => 'Leistungsumfang, Dauer, Termin und Uebergabe.',
245 'active' => 1,
246 'sorter' => 20,
247 ));
248
249 $this->updateShippingGroup(0, array(
250 'group_key' => 'digital-free',
251 'title' => 'Digital / kein Versand',
252 'description' => 'Digitale Lieferung ohne Versandkosten.',
253 'shipping_way' => 'Download / Freischaltung',
254 'delivery_time' => 'Sofort nach Freischaltung',
255 'shipping_gross' => 0,
256 'free_from_gross' => -1,
257 'active' => 1,
258 'sorter' => 10,
259 ));
260 $this->updateShippingGroup(0, array(
261 'group_key' => 'service-remote',
262 'title' => 'Service / Termin',
263 'description' => 'Terminleistung ohne Paketversand.',
264 'shipping_way' => 'Termin / Remote / vor Ort nach Absprache',
265 'delivery_time' => 'Nach Terminvereinbarung',
266 'shipping_gross' => 0,
267 'free_from_gross' => -1,
268 'active' => 1,
269 'sorter' => 20,
270 ));
271
272 $this->updateChannelGroup(0, array(
273 'group_key' => 'software-shop',
274 'title' => 'Software Shop-Artikel',
275 'description' => 'Softwarepakete fuer Shop und Amazon.',
276 'active' => 1,
277 'sorter' => 20,
278 ), array('shop', 'amazon'));
279 $this->updateChannelGroup(0, array(
280 'group_key' => 'service-local',
281 'title' => 'Service lokal',
282 'description' => 'Dienstleistungen fuer Shop und Kleinanzeigen.',
283 'active' => 1,
284 'sorter' => 30,
285 ), array('shop', 'kleinanzeigen'));
286
287 $products = array(
288 array('DBX-DEMO-START', 'dbxapp-demo-paket', 'dbXapp Demo Paket', 'Software', 'digital', 'software', 'digital-free', 'software-shop', 'Kleine Website mit CMS, Medienverwaltung und direkter Frontend-Bearbeitung.', 'Testartikel fuer den Einstieg: Content-Seiten, Hero, Gallery und Medienbrowser sind vorbereitet.', 99.00, 0, 10, 'Demo', 'bi-window-sidebar', array('shop', 'amazon')),
289 array('DBX-INSTALLATION', 'dbxapp-installation', 'Installation', 'Dienstleistung', 'service', 'service', 'service-remote', 'service-local', 'Einrichtung von dbXapp auf Hosting oder eigenem Server.', 'Installation, Grundkonfiguration, erste Systempruefung und Uebergabe der lauffaehigen Umgebung.', 249.00, 0, 40, 'Service', 'bi-tools', array('shop', 'kleinanzeigen')),
290 );
291 foreach ($products as $p) {
292 $this->db()->save(
293 $this->dd('shopProduct'),
294 array(
295 'sku' => $p[0],
296 'slug' => $p[1],
297 'title' => $p[2],
298 'category' => $p[3],
299 'product_group_id' => $this->groupIdByKey($p[5]),
300 'product_type' => $p[4],
301 'summary' => $p[8],
302 'description' => $p[9],
303 'price_gross' => $p[10],
304 'currency' => 'EUR',
305 'tax_mode' => 'group',
306 'tax_rate' => -1,
307 'shipping_mode' => 'group',
308 'shipping_gross' => -1,
309 'stock' => $p[11],
310 'active' => 1,
311 'sorter' => $p[12],
312 'badge' => $p[13],
313 'image_icon' => $p[14],
314 'logo_variant' => '',
315 ),
316 'sku = ' . $this->sqlValue($p[0]),
317 0
318 );
319 $product = $this->productBySku($p[0], false);
320 $productId = (int)($product['id'] ?? 0);
321 if ($productId <= 0) continue;
322 $groupId = $this->groupIdByKey($p[5]);
323 if ($groupId > 0) {
324 $this->db()->save($this->dd('shopProductGroupMap'), array('product_id' => $productId, 'group_id' => $groupId, 'is_primary' => 1), 'product_id = ' . $productId . ' AND group_id = ' . $groupId, 0);
325 }
326 $shippingGroupId = $this->shippingGroupIdByKey($p[6]);
327 if ($shippingGroupId > 0) {
328 $this->db()->save($this->dd('shopProductShippingGroupMap'), array('product_id' => $productId, 'shipping_group_id' => $shippingGroupId, 'is_primary' => 1), 'product_id = ' . $productId . ' AND shipping_group_id = ' . $shippingGroupId, 0);
329 }
330 $channelGroupId = $this->channelGroupIdByKey($p[7]);
331 if ($channelGroupId > 0) {
332 $this->db()->save($this->dd('shopProductChannelGroupMap'), array('product_id' => $productId, 'channel_group_id' => $channelGroupId, 'is_primary' => 1), 'product_id = ' . $productId . ' AND channel_group_id = ' . $channelGroupId, 0);
333 }
334 foreach ($p[15] as $channelKey) {
335 $this->db()->save($this->dd('shopProductChannel'), array('product_id' => $productId, 'channel_key' => $channelKey, 'active' => 1, 'channel_sku' => $p[0], 'price_gross' => -1, 'shipping_gross' => -1), 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey), 0);
336 }
337 }
338
339 $softwareGroupId = $this->groupIdByKey('software');
340 if ($softwareGroupId > 0) {
341 $this->saveImage(0, $softwareGroupId, 'files/shop/img/software-dashboard.svg', 'Software Dashboard', 'dbXapp Software Dashboard', 1, 10);
342 }
343 $serviceGroupId = $this->groupIdByKey('service');
344 if ($serviceGroupId > 0) {
345 $this->saveImage(0, $serviceGroupId, 'files/shop/img/service-support.svg', 'Service und Schulung', 'dbXapp Installation, Wartung und Schulung', 1, 10);
346 }
347 }
348
349 public function seedDemoProducts(): void {
350 $this->install(true);
351 $this->seedDemoProductsWithDbxDb();
352 $this->clearRequestCache();
353 return;
354 }
355
357 public function needsDemoSeed(): bool {
358 $this->install();
359 return $this->db()->count($this->dd('shopProduct'), 'trash = 0') === 0
360 || $this->db()->count($this->dd('shopShippingGroup'), 'trash = 0') === 0
361 || $this->db()->count($this->dd('shopChannelGroup'), 'trash = 0') === 0
362 || $this->db()->count($this->dd('shopProductImage'), 'trash = 0') === 0;
363 }
364
365 private function groupIdByKey(string $key): int {
366 $row = $this->db()->select1($this->dd('shopProductGroup'), 'group_key = ' . $this->sqlValue($key), 'id', 0);
367 return (int)($row['id'] ?? 0);
368 }
369
370 private function shippingGroupIdByKey(string $key): int {
371 $row = $this->db()->select1($this->dd('shopShippingGroup'), 'group_key = ' . $this->sqlValue($key), 'id', 0);
372 return (int)($row['id'] ?? 0);
373 }
374
375 private function channelGroupIdByKey(string $key): int {
376 $row = $this->db()->select1($this->dd('shopChannelGroup'), 'group_key = ' . $this->sqlValue($key), 'id', 0);
377 return (int)($row['id'] ?? 0);
378 }
379
380 private function valueNum(string $value): ?float {
381 $clean = str_replace(',', '.', trim($value));
382 return is_numeric($clean) ? (float)$clean : null;
383 }
384
385 public function attributeDefinitionsForGroup(int $groupId, bool $activeOnly = true): array {
386 $this->install();
387 $where = 'trash = 0 AND group_id = ' . (int)$groupId;
388 if ($activeOnly) {
389 $where .= ' AND active = 1';
390 }
391 $rows = $this->db()->select($this->dd('shopAttributeDefinition'), $where, '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
392 return is_array($rows) ? $rows : array();
393 }
394
395 public function attributeDefinitionsForProduct(int $productId, bool $activeOnly = true): array {
396 $defs = array();
397 $seen = array();
398 foreach ($this->groupsForProduct($productId) as $group) {
399 foreach ($this->attributeDefinitionsForGroup((int)($group['id'] ?? 0), $activeOnly) as $definition) {
400 $id = (int)($definition['id'] ?? 0);
401 if ($id <= 0 || isset($seen[$id])) continue;
402 $seen[$id] = true;
403 $defs[] = $definition;
404 }
405 }
406 return $defs;
407 }
408
409 public function attributesForProduct(int $productId): array {
410 $this->install();
411 $values = $this->db()->select($this->dd('shopProductAttributeValue'), 'product_id = ' . (int)$productId . ' AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
412 $valueByAttribute = array();
413 foreach ((is_array($values) ? $values : array()) as $value) {
414 $valueByAttribute[(int)($value['attribute_id'] ?? 0)] = $value;
415 }
416
417 $rows = array();
418 foreach ($this->attributeDefinitionsForProduct($productId, true) as $definition) {
419 $attributeId = (int)($definition['id'] ?? 0);
420 if ($attributeId <= 0) {
421 continue;
422 }
423 $value = $valueByAttribute[$attributeId] ?? array();
424 $definition['value_text'] = $value['value_text'] ?? '';
425 $definition['value_num'] = $value['value_num'] ?? '';
426 $definition['unit_override'] = $value['unit_override'] ?? '';
427 $definition['value_active'] = $value['active'] ?? 0;
428 $rows[] = $definition;
429 }
430 foreach ($rows as &$row) {
431 $value = trim((string)($row['value_text'] ?? ''));
432 $unit = trim((string)($row['unit_override'] ?? '')) ?: trim((string)($row['unit'] ?? ''));
433 $row['display_value'] = $value !== '' && $unit !== '' ? $value . ' ' . $unit : $value;
434 }
435 unset($row);
436 return $rows;
437 }
438
439 public function allAttributeDefinitions(): array {
440 $this->install();
441 return $this->remember('attribute_definitions', function(): array {
442 $groups = $this->groups();
443 $groupById = array();
444 foreach ($groups as $group) {
445 $groupById[(int)($group['id'] ?? 0)] = $group;
446 }
447 $defs = $this->db()->select($this->dd('shopAttributeDefinition'), 'trash = 0', '*', '', 'ASC', '', 0, 0, 0);
448 $defs = is_array($defs) ? $defs : array();
449 foreach ($defs as &$def) {
450 $group = $groupById[(int)($def['group_id'] ?? 0)] ?? array();
451 $def['group_title'] = (string)($group['title'] ?? '');
452 $def['group_key'] = (string)($group['group_key'] ?? '');
453 $def['_group_sorter'] = (int)($group['sorter'] ?? 9999);
454 }
455 unset($def);
456 usort($defs, fn($a, $b) => ((int)($a['_group_sorter'] ?? 9999) <=> (int)($b['_group_sorter'] ?? 9999))
457 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
458 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
459 foreach ($defs as &$def) {
460 unset($def['_group_sorter']);
461 }
462 unset($def);
463 return $defs;
464 });
465 }
466
467 public function attributeFilterDefinitions(): array {
468 $this->install();
469 return $this->remember('attribute_filter_definitions', function(): array {
470 $defs = array_values(array_filter(
472 fn($def) => (int)($def['active'] ?? 0) === 1 && (int)($def['filterable'] ?? 0) === 1
473 ));
474 $products = $this->db()->select($this->dd('shopProduct'), 'trash = 0 AND active = 1', 'id', '', 'ASC', '', 0, 0, 0);
475 $activeProducts = array();
476 foreach ((is_array($products) ? $products : array()) as $product) {
477 $activeProducts[(int)($product['id'] ?? 0)] = true;
478 }
479 $definitionIds = array_values(array_filter(array_map(
480 static fn($def) => (int)($def['id'] ?? 0),
481 $defs
482 )));
483 $valuesByDefinition = array();
484 if ($definitionIds && $activeProducts) {
485 $rows = $this->db()->select(
486 $this->dd('shopProductAttributeValue'),
487 'attribute_id IN (' . implode(',', $definitionIds) . ')'
488 . ' AND product_id IN (' . implode(',', array_keys($activeProducts)) . ')'
489 . ' AND trash = 0 AND active = 1',
490 'attribute_id,product_id,value_text',
491 'value_text',
492 'ASC',
493 '',
494 0,
495 0,
496 0
497 );
498 foreach ((is_array($rows) ? $rows : array()) as $row) {
499 $attributeId = (int)($row['attribute_id'] ?? 0);
500 $value = trim((string)($row['value_text'] ?? ''));
501 if ($attributeId > 0 && $value !== '') {
502 $valuesByDefinition[$attributeId][$value] = $value;
503 }
504 }
505 }
506 foreach ($defs as &$def) {
507 $values = array_values($valuesByDefinition[(int)($def['id'] ?? 0)] ?? array());
508 natcasesort($values);
509 $values = array_values($values);
510 if ($values === array() && trim((string)($def['options'] ?? '')) !== '') {
511 $values = preg_split('~[|;\r\n]+~', (string)$def['options']) ?: array();
512 $values = array_values(array_filter(array_map('trim', $values), fn($v) => $v !== ''));
513 }
514 $def['values'] = $values;
515 }
516 unset($def);
517 return $defs;
518 });
519 }
520
521 public function saveAttributeDefinition(array $data): void {
522 $this->install();
523 $id = (int)($data['id'] ?? 0);
524 $groupId = (int)($data['group_id'] ?? 0);
525 $key = strtolower(trim(preg_replace('~[^a-z0-9_\\-]+~i', '_', (string)($data['attr_key'] ?? ''))));
526 $title = trim((string)($data['title'] ?? ''));
527 if ($groupId <= 0 || $key === '' || $title === '') {
528 return;
529 }
530 $type = (string)($data['input_type'] ?? 'text');
531 if (!in_array($type, array('text', 'select', 'number'), true)) {
532 $type = 'text';
533 }
534 $values = array(
535 'group_id' => $groupId,
536 'attr_key' => $key,
537 'title' => $title,
538 'input_type' => $type,
539 'unit' => trim((string)($data['unit'] ?? '')),
540 'options' => trim((string)($data['options'] ?? '')),
541 'required' => !empty($data['required']) ? 1 : 0,
542 'filterable' => !empty($data['filterable']) ? 1 : 0,
543 'comparable' => !empty($data['comparable']) ? 1 : 0,
544 'active' => !empty($data['active']) ? 1 : 0,
545 'sorter' => (int)($data['sorter'] ?? 100),
546 );
547 if ($id > 0) {
548 $this->db()->update($this->dd('shopAttributeDefinition'), $values, 'id = ' . (int)$id, 0);
549 $this->clearRequestCache();
550 return;
551 }
552 $this->db()->save(
553 $this->dd('shopAttributeDefinition'),
554 $values,
555 'group_id = ' . (int)$groupId . ' AND attr_key = ' . $this->sqlValue($key),
556 0
557 );
558 $this->clearRequestCache();
559 }
560
561 public function saveProductAttributeValue(int $productId, int $attributeId, string $value): void {
562 $this->install();
563 if ($productId <= 0 || $attributeId <= 0) {
564 return;
565 }
566 $value = trim($value);
567 $num = $this->valueNum($value);
568 $this->db()->save(
569 $this->dd('shopProductAttributeValue'),
570 array(
571 'product_id' => $productId,
572 'attribute_id' => $attributeId,
573 'value_text' => $value,
574 'value_num' => $num,
575 'active' => $value !== '' ? 1 : 0,
576 ),
577 'product_id = ' . (int)$productId . ' AND attribute_id = ' . (int)$attributeId,
578 0
579 );
580 $this->clearRequestCache();
581 }
582
583 public function saveProductAttributeValues(int $productId, array $values): void {
584 foreach ($values as $attributeId => $value) {
585 $this->saveProductAttributeValue($productId, (int)$attributeId, (string)$value);
586 }
587 }
588
590 private function rowsByIntKey(array $rows, string $field): array {
591 $indexed = array();
592 foreach ($rows as $row) {
593 $key = (int)($row[$field] ?? 0);
594 if ($key > 0) {
595 $indexed[$key][] = $row;
596 }
597 }
598 return $indexed;
599 }
600
602 private function rowsById(array $rows): array {
603 $indexed = array();
604 foreach ($rows as $row) {
605 $id = (int)($row['id'] ?? 0);
606 if ($id > 0) {
607 $indexed[$id] = $row;
608 }
609 }
610 return $indexed;
611 }
612
618 private function mappedGroupRows(array $maps, array $groupById, string $groupIdField): array {
619 $rows = array();
620 foreach ($maps as $map) {
621 $groupId = (int)($map[$groupIdField] ?? 0);
622 if (!isset($groupById[$groupId])) continue;
623 $row = $groupById[$groupId];
624 $row['_is_primary'] = (int)($map['is_primary'] ?? 0);
625 $rows[] = $row;
626 }
627 usort($rows, fn($a, $b) => ((int)($b['_is_primary'] ?? 0) <=> (int)($a['_is_primary'] ?? 0))
628 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
629 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
630 foreach ($rows as &$row) {
631 unset($row['_is_primary']);
632 }
633 unset($row);
634 return $rows;
635 }
636
643 private function decorateProducts(array $rows): array {
644 if ($rows === array()) return array();
645
646 $productIds = array_values(array_unique(array_filter(array_map(
647 static fn($row) => (int)($row['id'] ?? 0),
648 $rows
649 ))));
650 if ($productIds === array()) return array();
651 $productIdSql = implode(',', array_map('intval', $productIds));
652
653 // Stammdaten und Zuordnungen werden je Operation genau einmal geladen.
654 $productGroups = $this->db()->select(
655 $this->dd('shopProductGroup'),
656 'trash = 0',
657 '*',
658 '',
659 'ASC',
660 '',
661 0,
662 0,
663 0
664 );
665 $productGroupById = $this->rowsById(is_array($productGroups) ? $productGroups : array());
666 $productGroupMaps = $this->db()->select(
667 $this->dd('shopProductGroupMap'),
668 'product_id IN (' . $productIdSql . ')',
669 '*',
670 '',
671 'ASC',
672 '',
673 0,
674 0,
675 0
676 );
677 $productGroupMapsByProduct = $this->rowsByIntKey(
678 is_array($productGroupMaps) ? $productGroupMaps : array(),
679 'product_id'
680 );
681
682 $shippingGroups = $this->db()->select(
683 $this->dd('shopShippingGroup'),
684 'trash = 0',
685 '*',
686 '',
687 'ASC',
688 '',
689 0,
690 0,
691 0
692 );
693 $shippingGroupById = $this->rowsById(is_array($shippingGroups) ? $shippingGroups : array());
694 $shippingMaps = $this->db()->select(
695 $this->dd('shopProductShippingGroupMap'),
696 'product_id IN (' . $productIdSql . ')',
697 '*',
698 '',
699 'ASC',
700 '',
701 0,
702 0,
703 0
704 );
705 $shippingMapsByProduct = $this->rowsByIntKey(
706 is_array($shippingMaps) ? $shippingMaps : array(),
707 'product_id'
708 );
709
710 $channelGroups = $this->db()->select(
711 $this->dd('shopChannelGroup'),
712 'trash = 0',
713 '*',
714 '',
715 'ASC',
716 '',
717 0,
718 0,
719 0
720 );
721 $channelGroupById = $this->rowsById(is_array($channelGroups) ? $channelGroups : array());
722 $channelGroupMaps = $this->db()->select(
723 $this->dd('shopProductChannelGroupMap'),
724 'product_id IN (' . $productIdSql . ')',
725 '*',
726 '',
727 'ASC',
728 '',
729 0,
730 0,
731 0
732 );
733 $channelGroupMaps = is_array($channelGroupMaps) ? $channelGroupMaps : array();
734 $channelGroupMapsByProduct = $this->rowsByIntKey($channelGroupMaps, 'product_id');
735
736 $channelIndex = array();
737 foreach ($this->channels() as $channel) {
738 $key = (string)($channel['channel_key'] ?? '');
739 if ($key !== '') $channelIndex[$key] = $channel;
740 }
741 $directChannels = $this->db()->select(
742 $this->dd('shopProductChannel'),
743 'product_id IN (' . $productIdSql . ')',
744 '*',
745 '',
746 'ASC',
747 '',
748 0,
749 0,
750 0
751 );
752 $directChannelsByProduct = $this->rowsByIntKey(
753 is_array($directChannels) ? $directChannels : array(),
754 'product_id'
755 );
756 $mappedChannelGroupIds = array_values(array_unique(array_filter(array_map(
757 static fn($map) => (int)($map['channel_group_id'] ?? 0),
758 $channelGroupMaps
759 ))));
760 $channelGroupChannelsByGroup = array();
761 if ($mappedChannelGroupIds !== array()) {
762 $channelGroupChannels = $this->db()->select(
763 $this->dd('shopChannelGroupChannel'),
764 'channel_group_id IN (' . implode(',', array_map('intval', $mappedChannelGroupIds)) . ') AND active = 1',
765 '*',
766 '',
767 'ASC',
768 '',
769 0,
770 0,
771 0
772 );
773 $channelGroupChannelsByGroup = $this->rowsByIntKey(
774 is_array($channelGroupChannels) ? $channelGroupChannels : array(),
775 'channel_group_id'
776 );
777 }
778
779 $definitions = $this->db()->select(
780 $this->dd('shopAttributeDefinition'),
781 'active = 1 AND trash = 0',
782 '*',
783 'sorter ASC, title ASC',
784 'ASC',
785 '',
786 0,
787 0,
788 0
789 );
790 $definitionsByGroup = $this->rowsByIntKey(
791 is_array($definitions) ? $definitions : array(),
792 'group_id'
793 );
794 $attributeValues = $this->db()->select(
795 $this->dd('shopProductAttributeValue'),
796 'product_id IN (' . $productIdSql . ') AND trash = 0',
797 '*',
798 '',
799 'ASC',
800 '',
801 0,
802 0,
803 0
804 );
805 $attributeValuesByProduct = array();
806 foreach ((is_array($attributeValues) ? $attributeValues : array()) as $value) {
807 $productId = (int)($value['product_id'] ?? 0);
808 $attributeId = (int)($value['attribute_id'] ?? 0);
809 if ($productId > 0 && $attributeId > 0) {
810 $attributeValuesByProduct[$productId][$attributeId] = $value;
811 }
812 }
813
814 // Fuer Gruppenbilder reichen die Artikelgruppen der aktuellen Menge.
815 $imageGroupIds = array();
816 foreach ($rows as $row) {
817 $productId = (int)($row['id'] ?? 0);
818 $directGroupId = (int)($row['product_group_id'] ?? 0);
819 if ($directGroupId > 0 && isset($productGroupById[$directGroupId])) {
820 $imageGroupIds[$directGroupId] = $directGroupId;
821 continue;
822 }
823 foreach ((array)($productGroupMapsByProduct[$productId] ?? array()) as $map) {
824 $groupId = (int)($map['group_id'] ?? 0);
825 if (isset($productGroupById[$groupId])) $imageGroupIds[$groupId] = $groupId;
826 }
827 }
828 $imageWhere = 'trash = 0 AND active = 1 AND (product_id IN (' . $productIdSql . ')';
829 if ($imageGroupIds !== array()) {
830 $imageWhere .= ' OR group_id IN (' . implode(',', array_map('intval', $imageGroupIds)) . ')';
831 }
832 $imageWhere .= ')';
833 $imageRows = $this->db()->select(
834 $this->dd('shopProductImage'),
835 $imageWhere,
836 '*',
837 'is_primary DESC, sorter ASC, title ASC',
838 'ASC',
839 '',
840 0,
841 0,
842 0
843 );
844 $imageRows = is_array($imageRows) ? $imageRows : array();
845 $imagesByProduct = $this->rowsByIntKey($imageRows, 'product_id');
846
847 foreach ($rows as &$row) {
848 $productId = (int)($row['id'] ?? 0);
849
850 $directGroupId = (int)($row['product_group_id'] ?? 0);
851 if ($directGroupId > 0 && isset($productGroupById[$directGroupId])) {
852 $row['groups'] = array($productGroupById[$directGroupId]);
853 } else {
854 $row['groups'] = $this->mappedGroupRows(
855 (array)($productGroupMapsByProduct[$productId] ?? array()),
856 $productGroupById,
857 'group_id'
858 );
859 }
860 $row['shipping_groups'] = $this->mappedGroupRows(
861 (array)($shippingMapsByProduct[$productId] ?? array()),
862 $shippingGroupById,
863 'shipping_group_id'
864 );
865 $row['channel_groups'] = $this->mappedGroupRows(
866 (array)($channelGroupMapsByProduct[$productId] ?? array()),
867 $channelGroupById,
868 'channel_group_id'
869 );
870
871 // Direkte Channel-Werte haben weiterhin Vorrang vor Vererbung.
872 $channels = array();
873 foreach ((array)($directChannelsByProduct[$productId] ?? array()) as $direct) {
874 $key = (string)($direct['channel_key'] ?? '');
875 $base = $channelIndex[$key] ?? array('title' => $key, 'sorter' => 9999);
876 $channels[] = array(
877 'channel_key' => $key,
878 'title' => (string)($base['title'] ?? $key),
879 'active' => (int)($direct['active'] ?? 0),
880 'channel_sku' => (string)($direct['channel_sku'] ?? ''),
881 'price_gross' => (float)($direct['price_gross'] ?? -1),
882 'shipping_gross' => (float)($direct['shipping_gross'] ?? -1),
883 '_sorter' => (int)($base['sorter'] ?? 9999),
884 );
885 }
886 usort($channels, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
887 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
888 foreach ((array)($channelGroupMapsByProduct[$productId] ?? array()) as $groupMap) {
889 $groupId = (int)($groupMap['channel_group_id'] ?? 0);
890 foreach ((array)($channelGroupChannelsByGroup[$groupId] ?? array()) as $inherited) {
891 $key = (string)($inherited['channel_key'] ?? '');
892 $base = $channelIndex[$key] ?? array('title' => $key, 'sorter' => 9999);
893 $channels[] = array(
894 'channel_key' => $key,
895 'title' => (string)($base['title'] ?? $key),
896 'active' => 1,
897 'channel_sku' => '',
898 'price_gross' => -1,
899 'shipping_gross' => -1,
900 '_sorter' => (int)($base['sorter'] ?? 9999),
901 );
902 }
903 }
904 $row['channels'] = array();
905 $seenChannels = array();
906 foreach ($channels as $channel) {
907 $key = (string)($channel['channel_key'] ?? '');
908 if ($key === '' || isset($seenChannels[$key])) continue;
909 $seenChannels[$key] = true;
910 if ((int)($channel['active'] ?? 0) === 1) {
911 unset($channel['_sorter']);
912 $row['channels'][] = $channel;
913 }
914 }
915
916 $groupIds = array();
917 foreach ($row['groups'] as $group) {
918 $groupId = (int)($group['id'] ?? 0);
919 if ($groupId > 0) $groupIds[$groupId] = true;
920 }
921 $images = (array)($imagesByProduct[$productId] ?? array());
922 foreach ($imageRows as $image) {
923 if (isset($groupIds[(int)($image['group_id'] ?? 0)])) $images[] = $image;
924 }
925 $row['images'] = array();
926 $seenImages = array();
927 foreach ($images as $image) {
928 $path = (string)($image['image_path'] ?? '');
929 $mediaId = (int)($image['media_id'] ?? 0);
930 $key = $mediaId > 0 ? 'm:' . $mediaId : 'p:' . $path;
931 if (($mediaId <= 0 && $path === '') || isset($seenImages[$key])) continue;
932 $seenImages[$key] = true;
933 $row['images'][] = $image;
934 }
935
936 $row['attributes'] = array();
937 $seenDefinitions = array();
938 foreach ($row['groups'] as $group) {
939 $groupId = (int)($group['id'] ?? 0);
940 foreach ((array)($definitionsByGroup[$groupId] ?? array()) as $definition) {
941 $attributeId = (int)($definition['id'] ?? 0);
942 if ($attributeId <= 0 || isset($seenDefinitions[$attributeId])) continue;
943 $seenDefinitions[$attributeId] = true;
944 $value = $attributeValuesByProduct[$productId][$attributeId] ?? array();
945 $definition['value_text'] = $value['value_text'] ?? '';
946 $definition['value_num'] = $value['value_num'] ?? '';
947 $definition['unit_override'] = $value['unit_override'] ?? '';
948 $definition['value_active'] = $value['active'] ?? 0;
949 $text = trim((string)$definition['value_text']);
950 $unit = trim((string)$definition['unit_override'])
951 ?: trim((string)($definition['unit'] ?? ''));
952 $definition['display_value'] = $text !== '' && $unit !== ''
953 ? $text . ' ' . $unit
954 : $text;
955 $row['attributes'][] = $definition;
956 }
957 }
958
959 $primary = $row['groups'][0] ?? array();
960 $shipping = $row['shipping_groups'][0] ?? array();
961 $row['effective_tax_rate'] = $this->taxRateForClass(
962 (string)($primary['tax_class'] ?? ''),
963 (float)($primary['default_tax_rate'] ?? 19)
964 );
965 $row['effective_shipping_gross'] = (string)($row['shipping_mode'] ?? 'group') === 'individual'
966 && (float)($row['shipping_gross'] ?? -1) >= 0
967 ? (float)$row['shipping_gross']
968 : (float)($shipping['shipping_gross'] ?? $primary['default_shipping_gross'] ?? 0);
969 $row['effective_shipping_way'] = (string)($shipping['shipping_way'] ?? '');
970 $row['effective_delivery_time'] = trim((string)($row['delivery_time'] ?? '')) !== ''
971 ? trim((string)$row['delivery_time'])
972 : trim((string)($shipping['delivery_time'] ?? ''));
973 }
974 unset($row);
975 return $rows;
976 }
977
978 private function decorateProduct(array $row): array {
979 $row['groups'] = $this->groupsForProduct((int)$row['id']);
980 $row['shipping_groups'] = $this->shippingGroupsForProduct((int)$row['id']);
981 $row['channel_groups'] = $this->channelGroupsForProduct((int)$row['id']);
982 $row['channels'] = $this->channelsForProduct((int)$row['id']);
983 $row['images'] = $this->imagesForProduct((int)$row['id'], $row['groups']);
984 $row['attributes'] = $this->attributesForProduct((int)$row['id']);
985 $primary = $row['groups'][0] ?? array();
986 $shipping = $row['shipping_groups'][0] ?? array();
987 $row['effective_tax_rate'] = $this->taxRateForClass(
988 (string)($primary['tax_class'] ?? ''),
989 (float)($primary['default_tax_rate'] ?? 19)
990 );
991 $row['effective_shipping_gross'] = (string)($row['shipping_mode'] ?? 'group') === 'individual' && (float)($row['shipping_gross'] ?? -1) >= 0
992 ? (float)$row['shipping_gross']
993 : (float)($shipping['shipping_gross'] ?? $primary['default_shipping_gross'] ?? 0);
994 $row['effective_shipping_way'] = (string)($shipping['shipping_way'] ?? '');
995 $row['effective_delivery_time'] = trim((string)($row['delivery_time'] ?? '')) !== ''
996 ? trim((string)$row['delivery_time'])
997 : trim((string)($shipping['delivery_time'] ?? ''));
998 return $row;
999 }
1000
1001 private function taxRatesConfig(): array {
1002 $fallback = array(
1003 'mwst1' => array('title' => 'MwSt. normal', 'rate' => '19'),
1004 'mwst2' => array('title' => 'MwSt. ermaessigt', 'rate' => '7'),
1005 'mwst3' => array('title' => 'MwSt. vorbereitet', 'rate' => '22'),
1006 );
1007 if (!function_exists('dbx')) {
1008 return $fallback;
1009 }
1010 $cfg = dbx()->get_config('dbxShop', 'tax_rates', $fallback);
1011 return is_array($cfg) && count($cfg) ? $cfg : $fallback;
1012 }
1013
1014 private function taxRateForClass(string $taxClass, float $fallback): float {
1015 $taxClass = trim($taxClass);
1016 $rates = $this->taxRatesConfig();
1017 if ($taxClass !== '' && isset($rates[$taxClass]) && is_array($rates[$taxClass])) {
1018 return (float)($rates[$taxClass]['rate'] ?? $fallback);
1019 }
1020 $defaultClass = function_exists('dbx') ? (string)dbx()->get_config('dbxShop', 'default_tax_class', 'mwst1') : 'mwst1';
1021 if (isset($rates[$defaultClass]) && is_array($rates[$defaultClass])) {
1022 return (float)($rates[$defaultClass]['rate'] ?? $fallback);
1023 }
1024 return $fallback;
1025 }
1026
1027 public function products(bool $activeOnly = true): array {
1028 $this->install();
1029 $where = $activeOnly ? 'active = 1 AND trash = 0' : 'trash = 0';
1030 $rows = $this->db()->select($this->dd('shopProduct'), $where, '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1031 $rows = is_array($rows) ? $rows : array();
1032 return $this->decorateProducts($rows);
1033 }
1034
1042 public function catalogCandidates(string $channelKey = 'shop'): array {
1043 $this->install();
1044 $channelKey = trim($channelKey);
1045 $rows = $this->db()->select(
1046 $this->dd('shopProduct'),
1047 'active = 1 AND trash = 0',
1048 '*',
1049 'sorter ASC, title ASC',
1050 'ASC',
1051 '',
1052 0,
1053 0,
1054 0
1055 );
1056 $rows = is_array($rows) ? $rows : array();
1057 if ($rows === array()) return array();
1058
1059 $ids = array_values(array_filter(array_map(
1060 static fn($row) => (int)($row['id'] ?? 0),
1061 $rows
1062 )));
1063 if ($ids === array()) return array();
1064 $idSql = implode(',', array_map('intval', $ids));
1065
1066 $groups = $this->groups();
1067 $groupById = array();
1068 foreach ($groups as $group) {
1069 $groupById[(int)($group['id'] ?? 0)] = $group;
1070 }
1071 $groupMaps = $this->db()->select(
1072 $this->dd('shopProductGroupMap'),
1073 'product_id IN (' . $idSql . ')',
1074 '*',
1075 'is_primary',
1076 'DESC',
1077 '',
1078 0,
1079 0,
1080 0
1081 );
1082 $groupMapsByProduct = array();
1083 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $map) {
1084 $productId = (int)($map['product_id'] ?? 0);
1085 $groupId = (int)($map['group_id'] ?? 0);
1086 if ($productId > 0 && $groupId > 0) {
1087 $groupMapsByProduct[$productId][] = $map;
1088 }
1089 }
1090
1091 $defs = $this->db()->select(
1092 $this->dd('shopAttributeDefinition'),
1093 'active = 1 AND trash = 0',
1094 '*',
1095 'sorter ASC, title ASC',
1096 'ASC',
1097 '',
1098 0,
1099 0,
1100 0
1101 );
1102 $defById = array();
1103 foreach ((is_array($defs) ? $defs : array()) as $def) {
1104 $defById[(int)($def['id'] ?? 0)] = $def;
1105 }
1106 $attributeValues = $this->db()->select(
1107 $this->dd('shopProductAttributeValue'),
1108 'product_id IN (' . $idSql . ') AND trash = 0',
1109 '*',
1110 '',
1111 'ASC',
1112 '',
1113 0,
1114 0,
1115 0
1116 );
1117 $attributeValuesByProduct = array();
1118 foreach ((is_array($attributeValues) ? $attributeValues : array()) as $value) {
1119 $productId = (int)($value['product_id'] ?? 0);
1120 $attributeId = (int)($value['attribute_id'] ?? 0);
1121 if ($productId > 0 && isset($defById[$attributeId])) {
1122 $attributeValuesByProduct[$productId][$attributeId] = $value;
1123 }
1124 }
1125
1126 // Direkte Channel-Zuordnungen haben wie in channelsForProduct()
1127 // Vorrang, auch wenn sie den geerbten Channel explizit deaktivieren.
1128 $directRows = $this->db()->select(
1129 $this->dd('shopProductChannel'),
1130 'product_id IN (' . $idSql . ') AND channel_key = ' . $this->sqlValue($channelKey),
1131 '*',
1132 '',
1133 'ASC',
1134 '',
1135 0,
1136 0,
1137 0
1138 );
1139 $directByProduct = array();
1140 foreach ((is_array($directRows) ? $directRows : array()) as $direct) {
1141 $directByProduct[(int)($direct['product_id'] ?? 0)] = $direct;
1142 }
1143 $channelGroupMaps = $this->db()->select(
1144 $this->dd('shopProductChannelGroupMap'),
1145 'product_id IN (' . $idSql . ')',
1146 '*',
1147 '',
1148 'ASC',
1149 '',
1150 0,
1151 0,
1152 0
1153 );
1154 $channelGroupIds = array();
1155 foreach ((is_array($channelGroupMaps) ? $channelGroupMaps : array()) as $map) {
1156 $groupId = (int)($map['channel_group_id'] ?? 0);
1157 if ($groupId > 0) $channelGroupIds[$groupId] = $groupId;
1158 }
1159 $activeChannelGroups = array();
1160 if ($channelGroupIds) {
1161 $groupChannels = $this->db()->select(
1162 $this->dd('shopChannelGroupChannel'),
1163 'channel_group_id IN (' . implode(',', $channelGroupIds) . ')'
1164 . ' AND channel_key = ' . $this->sqlValue($channelKey)
1165 . ' AND active = 1',
1166 'channel_group_id',
1167 '',
1168 'ASC',
1169 '',
1170 0,
1171 0,
1172 0
1173 );
1174 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $groupChannel) {
1175 $activeChannelGroups[(int)($groupChannel['channel_group_id'] ?? 0)] = true;
1176 }
1177 }
1178 $inheritedByProduct = array();
1179 foreach ((is_array($channelGroupMaps) ? $channelGroupMaps : array()) as $map) {
1180 $productId = (int)($map['product_id'] ?? 0);
1181 $groupId = (int)($map['channel_group_id'] ?? 0);
1182 if ($productId > 0 && isset($activeChannelGroups[$groupId])) {
1183 $inheritedByProduct[$productId] = true;
1184 }
1185 }
1186
1187 foreach ($rows as &$row) {
1188 $productId = (int)($row['id'] ?? 0);
1189 $rowGroups = array();
1190 $groupIds = array();
1191 $directGroupId = (int)($row['product_group_id'] ?? 0);
1192 if ($directGroupId > 0 && isset($groupById[$directGroupId])) {
1193 $rowGroups[] = $groupById[$directGroupId];
1194 $groupIds[$directGroupId] = true;
1195 } else {
1196 foreach ((array)($groupMapsByProduct[$productId] ?? array()) as $map) {
1197 $groupId = (int)($map['group_id'] ?? 0);
1198 if (isset($groupById[$groupId])) {
1199 $rowGroups[] = $groupById[$groupId];
1200 $groupIds[$groupId] = true;
1201 }
1202 }
1203 }
1204 $row['groups'] = $rowGroups;
1205
1206 $row['attributes'] = array();
1207 foreach ((array)($attributeValuesByProduct[$productId] ?? array()) as $attributeId => $value) {
1208 $def = $defById[(int)$attributeId] ?? array();
1209 if (!$def || !isset($groupIds[(int)($def['group_id'] ?? 0)])) continue;
1210 $def['value_text'] = $value['value_text'] ?? '';
1211 $def['value_num'] = $value['value_num'] ?? '';
1212 $def['unit_override'] = $value['unit_override'] ?? '';
1213 $def['value_active'] = $value['active'] ?? 0;
1214 $text = trim((string)$def['value_text']);
1215 $unit = trim((string)$def['unit_override']) ?: trim((string)($def['unit'] ?? ''));
1216 $def['display_value'] = $text !== '' && $unit !== '' ? $text . ' ' . $unit : $text;
1217 $row['attributes'][] = $def;
1218 }
1219
1220 $direct = $directByProduct[$productId] ?? null;
1221 $channelActive = is_array($direct)
1222 ? (int)($direct['active'] ?? 0) === 1
1223 : isset($inheritedByProduct[$productId]);
1224 $row['channels'] = $channelActive
1225 ? array(array('channel_key' => $channelKey, 'active' => 1))
1226 : array();
1227 }
1228 unset($row);
1229 return $rows;
1230 }
1231
1233 public function productsByIds(array $ids): array {
1234 $this->install();
1235 $ids = $this->normalizeProductIds($ids);
1236 if ($ids === array()) return array();
1237 $rows = $this->db()->select(
1238 $this->dd('shopProduct'),
1239 'id IN (' . implode(',', $ids) . ') AND active = 1 AND trash = 0',
1240 '*',
1241 '',
1242 'ASC',
1243 '',
1244 0,
1245 0,
1246 0
1247 );
1248 $byId = array();
1249 foreach ($this->decorateProducts(is_array($rows) ? $rows : array()) as $row) {
1250 $byId[(int)($row['id'] ?? 0)] = $row;
1251 }
1252 $result = array();
1253 foreach ($ids as $id) {
1254 if (isset($byId[$id])) $result[] = $byId[$id];
1255 }
1256 return $result;
1257 }
1258
1259 public function productBySku(string $sku, bool $activeOnly = true): ?array {
1260 $this->install();
1261 $where = 'sku = ' . $this->sqlValue($sku) . ' AND trash = 0';
1262 if ($activeOnly) {
1263 $where .= ' AND active = 1';
1264 }
1265 $row = $this->db()->select1($this->dd('shopProduct'), $where, '*', 0);
1266 return is_array($row) ? $this->decorateProduct($row) : null;
1267 }
1268
1269 public function productById(int $id): ?array {
1270 $this->install();
1271 $row = $this->db()->select1($this->dd('shopProduct'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
1272 return is_array($row) ? $this->decorateProduct($row) : null;
1273 }
1274
1275 public function groupById(int $id): ?array {
1276 $this->install();
1277 if ($id <= 0) {
1278 return null;
1279 }
1280 $row = $this->db()->select1($this->dd('shopProductGroup'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
1281 return is_array($row) ? $row : null;
1282 }
1283
1284 private function stockEnabled(): bool {
1285 $cfg = function_exists('dbx') ? dbx()->get_config('dbxShop', '', array()) : array();
1286 return is_array($cfg) && !empty($cfg['stock_enabled']);
1287 }
1288
1289 private function requiresStock(array $product): bool {
1290 return $this->stockEnabled() && (string)($product['product_type'] ?? '') === 'physical';
1291 }
1292
1293 private function isPhysicalProduct(array $product): bool {
1294 return (string)($product['product_type'] ?? '') === 'physical';
1295 }
1296
1297 public function stockIssuesForItems(array $items): array {
1298 $this->install();
1299 $issues = array();
1300 foreach ($items as $sku => $qty) {
1301 $qty = max(1, (int)$qty);
1302 $product = $this->productBySku((string)$sku);
1303 if (!$product || !$this->requiresStock($product)) {
1304 continue;
1305 }
1306 $stock = (int)($product['stock'] ?? 0);
1307 if ($stock < $qty) {
1308 $issues[] = array(
1309 'sku' => (string)($product['sku'] ?? $sku),
1310 'title' => (string)($product['title'] ?? $sku),
1311 'requested' => $qty,
1312 'stock' => $stock,
1313 );
1314 }
1315 }
1316 return $issues;
1317 }
1318
1319 private function hasReservableStockSnapshots(array $snapshots): bool {
1320 if (!$this->stockEnabled()) {
1321 return false;
1322 }
1323 foreach ($snapshots as $item) {
1324 if ((int)($item['product_id'] ?? 0) > 0
1325 && (string)($item['product_type'] ?? '') === 'physical') return true;
1326 }
1327 return false;
1328 }
1329
1330 private function reserveStockForSnapshots(array $snapshots): int {
1331 if (!$this->stockEnabled()) {
1332 return 0;
1333 }
1334 $reserved = 0;
1335 $db = $this->db();
1336 $server = $db->get_dd_server($this->dd('shopProduct'));
1337 $table = $db->get_dd_table($this->dd('shopProduct'));
1338 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
1339 $now = date('Y-m-d H:i:s');
1340 foreach ($snapshots as $item) {
1341 $productId = (int)($item['product_id'] ?? 0);
1342 if ($productId <= 0 || (string)($item['product_type'] ?? '') !== 'physical') {
1343 continue;
1344 }
1345 $qty = max(1, (int)($item['qty'] ?? 1));
1346 $sql = 'UPDATE ' . $table
1347 . ' SET stock = stock - ' . $qty
1348 . ', update_date = ' . $this->sqlValue($now)
1349 . ', update_uid = ' . $uid
1350 . ' WHERE id = ' . $productId
1351 . ' AND trash = 0 AND stock >= ' . $qty;
1352 if ((int)$db->update_query($server, $sql) !== 1) {
1353 throw new \RuntimeException(
1354 'Nicht genuegend Lagerbestand fuer ' . (string)($item['title'] ?? $item['sku'] ?? 'Artikel') . '.'
1355 );
1356 }
1357 $reserved += $qty;
1358 }
1359 return $reserved;
1360 }
1361
1362 private function releaseStockForOrder(array $order, string $reason): bool {
1363 $orderId = (int)($order['id'] ?? 0);
1364 if ($orderId <= 0 || (int)($order['stock_reserved'] ?? 0) !== 1 || (int)($order['stock_released'] ?? 0) === 1) {
1365 return false;
1366 }
1367
1368 $db = $this->db();
1369 if ($db->begin($this->dd('shopOrder')) !== 1) {
1370 return false;
1371 }
1372
1373 try {
1374 $now = date('Y-m-d H:i:s');
1375 if ($db->update($this->dd('shopOrder'), array(
1376 'stock_released' => 1,
1377 'stock_released_date' => $now,
1378 'update_date' => $now,
1379 ), 'id = ' . $orderId . ' AND trash = 0 AND stock_reserved = 1 AND stock_released = 0', 0) !== 1
1380 || (int)$db->_update_count !== 1) {
1381 $db->rollback($this->dd('shopOrder'));
1382 return false;
1383 }
1384
1385 $released = 0;
1386 $server = $db->get_dd_server($this->dd('shopProduct'));
1387 $table = $db->get_dd_table($this->dd('shopProduct'));
1388 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
1389 foreach ((array)($order['items'] ?? array()) as $item) {
1390 $productId = (int)($item['product_id'] ?? 0);
1391 if ($productId <= 0) continue;
1392 $product = $db->select1(
1393 $this->dd('shopProduct'),
1394 'id = ' . $productId . ' AND trash = 0',
1395 'id,product_type',
1396 0
1397 );
1398 if (!is_array($product) || !$this->isPhysicalProduct($product)) continue;
1399
1400 $qty = max(1, (int)($item['qty'] ?? 1));
1401 $sql = 'UPDATE ' . $table
1402 . ' SET stock = stock + ' . $qty
1403 . ', update_date = ' . $this->sqlValue($now)
1404 . ', update_uid = ' . $uid
1405 . ' WHERE id = ' . $productId . ' AND trash = 0';
1406 if ((int)$db->update_query($server, $sql) !== 1) {
1407 throw new \RuntimeException('stock_release_update_failed');
1408 }
1409 $released += $qty;
1410 }
1411
1412 if ($released <= 0
1413 || !$this->addOrderHistory($orderId, 'stock_release', '', (string)$released, $reason)
1414 || $db->commit($this->dd('shopOrder')) !== 1) {
1415 throw new \RuntimeException('stock_release_commit_failed');
1416 }
1417 return true;
1418 } catch (\Throwable $e) {
1419 $db->rollback($this->dd('shopOrder'));
1420 dbx()->debug('#Shop stock release rollback order=(' . $orderId . ') error=(' . $e->getMessage() . ')');
1421 return false;
1422 }
1423 }
1424
1425 private function normalizeProductIds(array $ids): array {
1426 $clean = array();
1427 foreach ($ids as $id) {
1428 $id = (int)$id;
1429 if ($id > 0) {
1430 $clean[$id] = $id;
1431 }
1432 }
1433 return array_values($clean);
1434 }
1435
1436 private function channelExists(string $channelKey): bool {
1437 $channelKey = trim($channelKey);
1438 if ($channelKey === '') {
1439 return false;
1440 }
1441 return $this->db()->count($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($channelKey) . ' AND trash = 0') > 0;
1442 }
1443
1444 private function productGroupExists(int $groupId): bool {
1445 if ($groupId <= 0) {
1446 return false;
1447 }
1448 return $this->db()->count($this->dd('shopProductGroup'), 'id = ' . (int)$groupId . ' AND trash = 0') > 0;
1449 }
1450
1451 public function deleteProducts(array $ids): int {
1452 $this->install();
1453 $ids = $this->normalizeProductIds($ids);
1454 if ($ids === array()) {
1455 return 0;
1456 }
1457
1458 $where = 'id IN (' . implode(',', array_map('intval', $ids)) . ') AND trash = 0';
1459 $count = (int)$this->db()->count($this->dd('shopProduct'), $where);
1460 if ($count <= 0) {
1461 return 0;
1462 }
1463 $this->db()->update(
1464 $this->dd('shopProduct'),
1465 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
1466 $where,
1467 0
1468 );
1469 return $count;
1470 }
1471
1472 public function addChannelToProducts(array $ids, string $channelKey): int {
1473 $this->install();
1474 $ids = $this->normalizeProductIds($ids);
1475 $channelKey = trim($channelKey);
1476 if ($ids === array() || !$this->channelExists($channelKey)) {
1477 return 0;
1478 }
1479
1480 $count = 0;
1481 foreach ($ids as $id) {
1482 $ok = $this->db()->save(
1483 $this->dd('shopProductChannel'),
1484 array('product_id' => $id, 'channel_key' => $channelKey, 'active' => 1, 'channel_sku' => '', 'price_gross' => -1, 'shipping_gross' => -1),
1485 'product_id = ' . (int)$id . ' AND channel_key = ' . $this->sqlValue($channelKey),
1486 0
1487 );
1488 if ($ok) {
1489 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0);
1490 $count++;
1491 }
1492 }
1493 return $count;
1494 }
1495
1496 public function removeChannelFromProducts(array $ids, string $channelKey): int {
1497 $this->install();
1498 $ids = $this->normalizeProductIds($ids);
1499 $channelKey = trim($channelKey);
1500 if ($ids === array() || !$this->channelExists($channelKey)) {
1501 return 0;
1502 }
1503
1504 $count = 0;
1505 foreach ($ids as $id) {
1506 $ok = $this->db()->save(
1507 $this->dd('shopProductChannel'),
1508 array('product_id' => $id, 'channel_key' => $channelKey, 'active' => 0, 'channel_sku' => '', 'price_gross' => -1, 'shipping_gross' => -1),
1509 'product_id = ' . (int)$id . ' AND channel_key = ' . $this->sqlValue($channelKey),
1510 0
1511 );
1512 if ($ok) {
1513 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0);
1514 $count++;
1515 }
1516 }
1517 return $count;
1518 }
1519
1520 public function setProductGroupForProducts(array $ids, int $groupId): int {
1521 $this->install();
1522 $ids = $this->normalizeProductIds($ids);
1523 if ($ids === array() || !$this->productGroupExists($groupId)) {
1524 return 0;
1525 }
1526
1527 $count = 0;
1528 foreach ($ids as $id) {
1529 $this->db()->update($this->dd('shopProductGroupMap'), array('is_primary' => 0), 'product_id = ' . (int)$id, 0);
1530 $ok = $this->db()->save(
1531 $this->dd('shopProductGroupMap'),
1532 array('product_id' => $id, 'group_id' => $groupId, 'is_primary' => 1),
1533 'product_id = ' . (int)$id . ' AND group_id = ' . (int)$groupId,
1534 0
1535 );
1536 if ($ok) {
1537 $this->db()->update($this->dd('shopProduct'), array('product_group_id' => $groupId, 'update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0);
1538 $count++;
1539 }
1540 }
1541 return $count;
1542 }
1543
1544 public function groupsForProduct(int $productId): array {
1545 $product = $this->db()->select1($this->dd('shopProduct'), 'id = ' . (int)$productId . ' AND trash = 0', 'product_group_id', 0);
1546 $directGroupId = (int)($product['product_group_id'] ?? 0);
1547 if ($directGroupId > 0) {
1548 $group = $this->groupById($directGroupId);
1549 if (is_array($group)) {
1550 return array($group);
1551 }
1552 }
1553
1554 $maps = $this->db()->select($this->dd('shopProductGroupMap'), 'product_id = ' . (int)$productId, '*', 'is_primary DESC', 'ASC', '', 0, 0, 0);
1555 if (!is_array($maps) || $maps === array()) {
1556 return array();
1557 }
1558 $groupIds = array_values(array_unique(array_map(fn($row) => (int)($row['group_id'] ?? 0), $maps)));
1559 $groupIds = array_values(array_filter($groupIds, fn($id) => $id > 0));
1560 if ($groupIds === array()) {
1561 return array();
1562 }
1563 $groups = $this->db()->select($this->dd('shopProductGroup'), 'id IN (' . implode(',', $groupIds) . ') AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
1564 $byId = array();
1565 foreach ((is_array($groups) ? $groups : array()) as $group) {
1566 $byId[(int)($group['id'] ?? 0)] = $group;
1567 }
1568 $rows = array();
1569 foreach ($maps as $map) {
1570 $id = (int)($map['group_id'] ?? 0);
1571 if (isset($byId[$id])) {
1572 $row = $byId[$id];
1573 $row['_is_primary'] = (int)($map['is_primary'] ?? 0);
1574 $rows[] = $row;
1575 }
1576 }
1577 usort($rows, fn($a, $b) => ((int)($b['_is_primary'] ?? 0) <=> (int)($a['_is_primary'] ?? 0))
1578 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
1579 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
1580 foreach ($rows as &$row) {
1581 unset($row['_is_primary']);
1582 }
1583 unset($row);
1584 return $rows;
1585 }
1586
1587 public function channelsForProduct(int $productId): array {
1588 $channelIndex = array();
1589 foreach ($this->channels() as $channel) {
1590 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
1591 }
1592
1593 $channels = array();
1594 $direct = $this->db()->select($this->dd('shopProductChannel'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
1595 foreach ((is_array($direct) ? $direct : array()) as $row) {
1596 $key = (string)($row['channel_key'] ?? '');
1597 $base = $channelIndex[$key] ?? array('channel_key' => $key, 'title' => $key, 'sorter' => 9999);
1598 $channels[] = array(
1599 'channel_key' => $key,
1600 'title' => (string)($base['title'] ?? $key),
1601 'active' => (int)($row['active'] ?? 0),
1602 'channel_sku' => (string)($row['channel_sku'] ?? ''),
1603 'price_gross' => (float)($row['price_gross'] ?? -1),
1604 'shipping_gross' => (float)($row['shipping_gross'] ?? -1),
1605 '_sorter' => (int)($base['sorter'] ?? 9999),
1606 );
1607 }
1608 usort($channels, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
1609 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
1610
1611 $groupMaps = $this->db()->select($this->dd('shopProductChannelGroupMap'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
1612 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
1613 $groupId = (int)($groupMap['channel_group_id'] ?? 0);
1614 if ($groupId <= 0) continue;
1615 $groupChannels = $this->db()->select($this->dd('shopChannelGroupChannel'), 'channel_group_id = ' . $groupId . ' AND active = 1', '*', '', 'ASC', '', 0, 0, 0);
1616 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
1617 $key = (string)($row['channel_key'] ?? '');
1618 $base = $channelIndex[$key] ?? array('channel_key' => $key, 'title' => $key, 'sorter' => 9999);
1619 $channels[] = array(
1620 'channel_key' => $key,
1621 'title' => (string)($base['title'] ?? $key),
1622 'active' => 1,
1623 'channel_sku' => '',
1624 'price_gross' => -1,
1625 'shipping_gross' => -1,
1626 '_sorter' => (int)($base['sorter'] ?? 9999),
1627 );
1628 }
1629 }
1630
1631 $seen = array();
1632 $clean = array();
1633 foreach ($channels as $channel) {
1634 $key = (string)($channel['channel_key'] ?? '');
1635 if ($key === '' || isset($seen[$key])) continue;
1636 $seen[$key] = true;
1637 if ((int)($channel['active'] ?? 0) === 1) {
1638 unset($channel['_sorter']);
1639 $clean[] = $channel;
1640 }
1641 }
1642 return $clean;
1643 }
1644
1645 public function productChannelOverrides(int $productId): array {
1646 $this->install();
1647 $channelIndex = array();
1648 foreach ($this->channels() as $channel) {
1649 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
1650 }
1651 $rows = $this->db()->select($this->dd('shopProductChannel'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
1652 $rows = is_array($rows) ? $rows : array();
1653 foreach ($rows as &$row) {
1654 $key = (string)($row['channel_key'] ?? '');
1655 $row['title'] = (string)($channelIndex[$key]['title'] ?? $key);
1656 $row['_sorter'] = (int)($channelIndex[$key]['sorter'] ?? 9999);
1657 }
1658 unset($row);
1659 usort($rows, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
1660 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
1661 $out = array();
1662 foreach ($rows as $row) {
1663 $key = (string)($row['channel_key'] ?? '');
1664 if ($key !== '') {
1665 unset($row['_sorter']);
1666 $out[$key] = $row;
1667 }
1668 }
1669 return $out;
1670 }
1671
1672 public function inheritedChannelsForProduct(int $productId): array {
1673 $this->install();
1674 $channelIndex = array();
1675 foreach ($this->channels() as $channel) {
1676 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
1677 }
1678 $groupMaps = $this->db()->select($this->dd('shopProductChannelGroupMap'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
1679 $rows = array();
1680 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
1681 $groupId = (int)($groupMap['channel_group_id'] ?? 0);
1682 if ($groupId <= 0) continue;
1683 $group = $this->db()->select1($this->dd('shopChannelGroup'), 'id = ' . $groupId . ' AND trash = 0 AND active = 1', '*', 0);
1684 if (!is_array($group)) continue;
1685 $groupChannels = $this->db()->select($this->dd('shopChannelGroupChannel'), 'channel_group_id = ' . $groupId . ' AND active = 1', '*', '', 'ASC', '', 0, 0, 0);
1686 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
1687 $key = (string)($row['channel_key'] ?? '');
1688 $base = $channelIndex[$key] ?? array('channel_key' => $key, 'title' => $key, 'sorter' => 9999);
1689 $rows[] = array(
1690 'channel_key' => $key,
1691 'title' => (string)($base['title'] ?? $key),
1692 'active' => (int)($row['active'] ?? 0),
1693 'group_title' => (string)($group['title'] ?? ''),
1694 '_sorter' => (int)($base['sorter'] ?? 9999),
1695 );
1696 }
1697 }
1698 usort($rows, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
1699 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
1700 $out = array();
1701 foreach ($rows as $row) {
1702 $key = (string)($row['channel_key'] ?? '');
1703 if ($key === '') {
1704 continue;
1705 }
1706 if (!isset($out[$key])) {
1707 $out[$key] = $row;
1708 $out[$key]['group_titles'] = array();
1709 }
1710 $groupTitle = trim((string)($row['group_title'] ?? ''));
1711 if ($groupTitle !== '') {
1712 $out[$key]['group_titles'][$groupTitle] = $groupTitle;
1713 }
1714 unset($out[$key]['_sorter']);
1715 }
1716 return $out;
1717 }
1718
1719 public function saveProductChannelOverrides(int $productId, array $activeChannelKeys): void {
1720 $this->install();
1721 $productId = max(0, $productId);
1722 if ($productId <= 0) {
1723 return;
1724 }
1725
1726 $product = $this->productById($productId);
1727 if (!$product) {
1728 return;
1729 }
1730
1731 $active = array();
1732 foreach ($activeChannelKeys as $key) {
1733 $key = trim((string)$key);
1734 if ($key !== '') {
1735 $active[$key] = true;
1736 }
1737 }
1738
1739 foreach ($this->channels() as $channel) {
1740 $key = trim((string)($channel['channel_key'] ?? ''));
1741 if ($key === '') {
1742 continue;
1743 }
1744 $existing = $this->db()->select1(
1745 $this->dd('shopProductChannel'),
1746 'product_id = ' . (int)$productId . ' AND channel_key = ' . $this->sqlValue($key),
1747 '*',
1748 0
1749 );
1750 $existing = is_array($existing) ? $existing : array();
1751 $channelSku = trim((string)($existing['channel_sku'] ?? ''));
1752 if ($channelSku === '') {
1753 $channelSku = (string)($product['sku'] ?? '');
1754 }
1755 $this->db()->save(
1756 $this->dd('shopProductChannel'),
1757 array(
1758 'product_id' => $productId,
1759 'channel_key' => $key,
1760 'active' => isset($active[$key]) ? 1 : 0,
1761 'channel_sku' => $channelSku,
1762 'price_gross' => (float)($existing['price_gross'] ?? -1),
1763 'shipping_gross' => (float)($existing['shipping_gross'] ?? -1),
1764 'external_listing_id' => (string)($existing['external_listing_id'] ?? ''),
1765 'external_offer_id' => (string)($existing['external_offer_id'] ?? ''),
1766 'export_status' => (string)($existing['export_status'] ?? ''),
1767 'export_message' => (string)($existing['export_message'] ?? ''),
1768 'export_payload' => (string)($existing['export_payload'] ?? ''),
1769 'last_export_date' => (string)($existing['last_export_date'] ?? ''),
1770 ),
1771 'product_id = ' . (int)$productId . ' AND channel_key = ' . $this->sqlValue($key),
1772 0
1773 );
1774 }
1775
1776 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$productId . ' AND trash = 0', 0);
1777 }
1778
1779 public function shippingGroupsForProduct(int $productId): array {
1780 $maps = $this->db()->select($this->dd('shopProductShippingGroupMap'), 'product_id = ' . (int)$productId, '*', 'is_primary DESC', 'ASC', '', 0, 0, 0);
1781 if (!is_array($maps) || $maps === array()) {
1782 return array();
1783 }
1784 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (int)($row['shipping_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1785 if ($ids === array()) {
1786 return array();
1787 }
1788 $groups = $this->db()->select($this->dd('shopShippingGroup'), 'id IN (' . implode(',', $ids) . ') AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
1789 $byId = array();
1790 foreach ((is_array($groups) ? $groups : array()) as $group) {
1791 $byId[(int)($group['id'] ?? 0)] = $group;
1792 }
1793 $rows = array();
1794 foreach ($maps as $map) {
1795 $id = (int)($map['shipping_group_id'] ?? 0);
1796 if (isset($byId[$id])) {
1797 $row = $byId[$id];
1798 $row['_is_primary'] = (int)($map['is_primary'] ?? 0);
1799 $rows[] = $row;
1800 }
1801 }
1802 usort($rows, fn($a, $b) => ((int)($b['_is_primary'] ?? 0) <=> (int)($a['_is_primary'] ?? 0))
1803 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
1804 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
1805 foreach ($rows as &$row) {
1806 unset($row['_is_primary']);
1807 }
1808 unset($row);
1809 return $rows;
1810 }
1811
1812 public function channelGroupsForProduct(int $productId): array {
1813 $maps = $this->db()->select($this->dd('shopProductChannelGroupMap'), 'product_id = ' . (int)$productId, '*', 'is_primary DESC', 'ASC', '', 0, 0, 0);
1814 if (!is_array($maps) || $maps === array()) {
1815 return array();
1816 }
1817 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (int)($row['channel_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1818 if ($ids === array()) {
1819 return array();
1820 }
1821 $groups = $this->db()->select($this->dd('shopChannelGroup'), 'id IN (' . implode(',', $ids) . ') AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
1822 $byId = array();
1823 foreach ((is_array($groups) ? $groups : array()) as $group) {
1824 $byId[(int)($group['id'] ?? 0)] = $group;
1825 }
1826 $rows = array();
1827 foreach ($maps as $map) {
1828 $id = (int)($map['channel_group_id'] ?? 0);
1829 if (isset($byId[$id])) {
1830 $row = $byId[$id];
1831 $row['_is_primary'] = (int)($map['is_primary'] ?? 0);
1832 $rows[] = $row;
1833 }
1834 }
1835 usort($rows, fn($a, $b) => ((int)($b['_is_primary'] ?? 0) <=> (int)($a['_is_primary'] ?? 0))
1836 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
1837 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
1838 foreach ($rows as &$row) {
1839 unset($row['_is_primary']);
1840 }
1841 unset($row);
1842 return $rows;
1843 }
1844
1845 public function groups(): array {
1846 $this->install();
1847 return $this->remember('groups', function(): array {
1848 $rows = $this->db()->select($this->dd('shopProductGroup'), 'trash = 0', '*', 'parent_id ASC, sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1849 return is_array($rows) ? $rows : array();
1850 });
1851 }
1852
1853 public function groupsByParent(int $parentId = 0, bool $activeOnly = true): array {
1854 $this->install();
1855 $where = 'trash = 0 AND parent_id = ' . max(0, (int)$parentId);
1856 if ($activeOnly) {
1857 $where .= ' AND active = 1';
1858 }
1859 $rows = $this->db()->select($this->dd('shopProductGroup'), $where, '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1860 return is_array($rows) ? $rows : array();
1861 }
1862
1863 public function groupPath(int $groupId): array {
1864 $this->install();
1865 $path = array();
1866 $seen = array();
1867 $current = max(0, (int)$groupId);
1868 while ($current > 0 && !isset($seen[$current])) {
1869 $seen[$current] = true;
1870 $group = $this->groupById($current);
1871 if (!is_array($group)) {
1872 break;
1873 }
1874 array_unshift($path, $group);
1875 $current = (int)($group['parent_id'] ?? 0);
1876 }
1877 return $path;
1878 }
1879
1880 private function wouldCreateGroupCycle(int $groupId, int $parentId): bool {
1881 if ($groupId <= 0 || $parentId <= 0) {
1882 return false;
1883 }
1884 if ($groupId === $parentId) {
1885 return true;
1886 }
1887 $seen = array($groupId => true);
1888 $current = $parentId;
1889 while ($current > 0 && !isset($seen[$current])) {
1890 $seen[$current] = true;
1891 $group = $this->groupById($current);
1892 if (!is_array($group)) {
1893 return false;
1894 }
1895 $current = (int)($group['parent_id'] ?? 0);
1896 if ($current === $groupId) {
1897 return true;
1898 }
1899 }
1900 return false;
1901 }
1902
1903 private function nextGroupSorter(int $parentId): int {
1904 $rows = $this->db()->select(
1905 $this->dd('shopProductGroup'),
1906 'trash = 0 AND parent_id = ' . max(0, (int)$parentId),
1907 'sorter',
1908 'sorter',
1909 'DESC',
1910 '',
1911 0,
1912 1,
1913 0
1914 );
1915 $max = is_array($rows) && isset($rows[0]) ? (int)($rows[0]['sorter'] ?? 0) : 0;
1916 return $max + 10;
1917 }
1918
1919 public function moveProductGroupParent(int $groupId, int $parentId): bool {
1920 $this->install();
1921 $groupId = max(0, (int)$groupId);
1922 $parentId = max(0, (int)$parentId);
1923 if ($groupId <= 0 || !is_array($this->groupById($groupId))) {
1924 return false;
1925 }
1926 if ($parentId > 0 && !is_array($this->groupById($parentId))) {
1927 return false;
1928 }
1929 if ($this->wouldCreateGroupCycle($groupId, $parentId)) {
1930 return false;
1931 }
1932 $this->db()->update(
1933 $this->dd('shopProductGroup'),
1934 array(
1935 'parent_id' => $parentId,
1936 'sorter' => $this->nextGroupSorter($parentId),
1937 'update_date' => date('Y-m-d H:i:s'),
1938 ),
1939 'id = ' . $groupId . ' AND trash = 0',
1940 0
1941 );
1942 $this->clearRequestCache();
1943 return true;
1944 }
1945
1946 public function channels(): array {
1947 $this->install();
1948 return $this->remember('channels', function(): array {
1949 $rows = $this->db()->select($this->dd('shopChannel'), 'trash = 0', '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1950 return is_array($rows) ? $rows : array();
1951 });
1952 }
1953
1954 public function channelById(int $id): ?array {
1955 $this->install();
1956 if ($id <= 0) {
1957 return null;
1958 }
1959 $row = $this->db()->select1($this->dd('shopChannel'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
1960 return is_array($row) ? $row : null;
1961 }
1962
1963 public function channelByKey(string $key): ?array {
1964 $this->install();
1965 $key = trim($key);
1966 if ($key === '') {
1967 return null;
1968 }
1969 $row = $this->db()->select1($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($key) . ' AND trash = 0', '*', 0);
1970 return is_array($row) ? $row : null;
1971 }
1972
1973 public function updateChannel(int $id, array $data): void {
1974 $this->install();
1975 $secretFields = array('api_client_secret', 'api_access_token', 'api_refresh_token', 'api_password', 'webhook_secret');
1976 $existing = $id > 0 ? ($this->channelById($id) ?: array()) : array();
1977 $values = array(
1978 'title' => (string)($data['title'] ?? ''),
1979 'description' => (string)($data['description'] ?? ''),
1980 'platform_type' => (string)($data['platform_type'] ?? 'custom'),
1981 'connection_mode' => (string)($data['connection_mode'] ?? 'manual'),
1982 'api_base_url' => (string)($data['api_base_url'] ?? ''),
1983 'api_client_id' => (string)($data['api_client_id'] ?? ''),
1984 'api_username' => (string)($data['api_username'] ?? ''),
1985 'marketplace_id' => (string)($data['marketplace_id'] ?? ''),
1986 'seller_id' => (string)($data['seller_id'] ?? ''),
1987 'account_id' => (string)($data['account_id'] ?? ''),
1988 'location_key' => (string)($data['location_key'] ?? ''),
1989 'category_id' => (string)($data['category_id'] ?? ''),
1990 'payment_policy_id' => (string)($data['payment_policy_id'] ?? ''),
1991 'fulfillment_policy_id' => (string)($data['fulfillment_policy_id'] ?? ''),
1992 'return_policy_id' => (string)($data['return_policy_id'] ?? ''),
1993 'notification_destination' => (string)($data['notification_destination'] ?? ''),
1994 'notification_topic' => (string)($data['notification_topic'] ?? ''),
1995 'api_scope' => (string)($data['api_scope'] ?? ''),
1996 'export_enabled' => !empty($data['export_enabled']) ? 1 : 0,
1997 'order_import_enabled' => !empty($data['order_import_enabled']) ? 1 : 0,
1998 'active' => !empty($data['active']) ? 1 : 0,
1999 'sorter' => (int)($data['sorter'] ?? 100),
2000 );
2001 foreach ($secretFields as $field) {
2002 $posted = (string)($data[$field] ?? '');
2003 $values[$field] = ($id > 0 && $posted === '') ? (string)($existing[$field] ?? '') : $posted;
2004 }
2005
2006 if ($id <= 0) {
2007 $channelKey = $this->normalizeKey((string)($data['channel_key'] ?? ''));
2008 if ($channelKey === '') {
2009 $channelKey = $this->normalizeKey((string)($data['title'] ?? 'channel'));
2010 }
2011 if ($channelKey === '') $channelKey = 'channel';
2012 $channelKey = $this->uniqueChannelKey($channelKey);
2013 $values['channel_key'] = $channelKey;
2014 $this->db()->insert($this->dd('shopChannel'), $values, 0);
2015 $this->clearRequestCache();
2016 return;
2017 }
2018
2019 $this->db()->update($this->dd('shopChannel'), $values, 'id = ' . (int)$id, 0);
2020 $this->clearRequestCache();
2021 }
2022
2023 public function deleteChannel(int $id): int {
2024 $this->install();
2025 if ($id <= 0) {
2026 return 0;
2027 }
2028
2029 $channel = $this->channelById($id);
2030 if (!$channel) {
2031 return 0;
2032 }
2033
2034 $updated = (int)$this->db()->update(
2035 $this->dd('shopChannel'),
2036 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
2037 'id = ' . (int)$id . ' AND trash = 0',
2038 0
2039 );
2040 if ($updated !== 1) {
2041 return 0;
2042 }
2043
2044 $this->db()->update(
2045 $this->dd('shopProductChannel'),
2046 array('active' => 0),
2047 'channel_key = ' . $this->sqlValue((string)($channel['channel_key'] ?? '')),
2048 0
2049 );
2050 $this->clearRequestCache();
2051 return 1;
2052 }
2053
2054 public function testChannelConnection(int $id): array {
2055 $this->install();
2056 $channel = $this->channelById($id);
2057 if (!$channel) {
2058 return array('ok' => false, 'message' => 'Channel wurde nicht gefunden.');
2059 }
2060
2061 $connector = dbx()->get_include_obj('dbxShopChannelConnector', 'dbxShop');
2062 $result = is_object($connector) && method_exists($connector, 'test')
2063 ? (array)$connector->test($channel)
2064 : array('ok' => false, 'message' => 'Channel-Connector konnte nicht geladen werden.');
2065
2066 $ok = !empty($result['ok']);
2067 $message = (string)($result['message'] ?? '');
2068 $this->saveChannelTestResult($id, $ok, $message);
2069 return array('ok' => $ok, 'message' => $message);
2070 }
2071
2072 private function productHasActiveChannel(array $product, string $channelKey): bool {
2073 foreach ((array)($product['channels'] ?? array()) as $channel) {
2074 if ((string)($channel['channel_key'] ?? '') === $channelKey && (int)($channel['active'] ?? 0) === 1) {
2075 return true;
2076 }
2077 }
2078 return false;
2079 }
2080
2081 private function productChannelRowForExport(array $product, string $channelKey): array {
2082 $productId = (int)($product['id'] ?? 0);
2083 $row = $this->db()->select1(
2084 $this->dd('shopProductChannel'),
2085 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
2086 '*',
2087 0
2088 );
2089 if (is_array($row)) {
2090 return $row;
2091 }
2092
2093 $values = array(
2094 'product_id' => $productId,
2095 'channel_key' => $channelKey,
2096 'active' => 1,
2097 'channel_sku' => (string)($product['sku'] ?? ''),
2098 'price_gross' => -1,
2099 'shipping_gross' => -1,
2100 'export_status' => 'ready',
2101 );
2102 $this->db()->save(
2103 $this->dd('shopProductChannel'),
2104 $values,
2105 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
2106 0
2107 );
2108 $row = $this->db()->select1(
2109 $this->dd('shopProductChannel'),
2110 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
2111 '*',
2112 0
2113 );
2114 return is_array($row) ? $row : $values;
2115 }
2116
2117 public function productChannelMapping(int $productId, string $channelKey): ?array {
2118 $this->install();
2119 $product = $this->productById($productId);
2120 if (!$product) {
2121 return null;
2122 }
2123 $channel = $this->channelByKey($channelKey);
2124 if (!$channel) {
2125 return null;
2126 }
2127 $row = $this->db()->select1(
2128 $this->dd('shopProductChannel'),
2129 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
2130 '*',
2131 0
2132 );
2133 if (!is_array($row)) {
2134 $row = array(
2135 'product_id' => $productId,
2136 'channel_key' => $channelKey,
2137 'active' => $this->productHasActiveChannel($product, $channelKey) ? 1 : 0,
2138 'channel_sku' => (string)($product['sku'] ?? ''),
2139 'price_gross' => -1,
2140 'shipping_gross' => -1,
2141 'note' => '',
2142 );
2143 }
2144 $note = trim((string)($row['note'] ?? ''));
2145 $mapping = $note !== '' ? json_decode($note, true) : array();
2146 if (!is_array($mapping)) {
2147 $mapping = array();
2148 }
2149 return array(
2150 'product' => $product,
2151 'channel' => $channel,
2152 'product_channel' => $row,
2153 'mapping' => $mapping,
2154 );
2155 }
2156
2157 public function saveProductChannelMapping(int $productId, string $channelKey, array $data): void {
2158 $this->install();
2159 $product = $this->productById($productId);
2160 $channel = $this->channelByKey($channelKey);
2161 if (!$product || !$channel) {
2162 return;
2163 }
2164
2165 $mapping = is_array($data['mapping'] ?? null) ? $data['mapping'] : array();
2166 $note = json_encode($mapping, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
2167 if ($note === false) {
2168 $note = '';
2169 }
2170
2171 $existing = $this->productChannelRowForExport($product, $channelKey);
2172 $this->db()->save(
2173 $this->dd('shopProductChannel'),
2174 array(
2175 'product_id' => $productId,
2176 'channel_key' => $channelKey,
2177 'active' => !empty($data['active']) ? 1 : 0,
2178 'channel_sku' => trim((string)($data['channel_sku'] ?? $product['sku'] ?? '')),
2179 'price_gross' => (float)($data['price_gross'] ?? -1),
2180 'shipping_gross' => (float)($data['shipping_gross'] ?? -1),
2181 'external_listing_id' => trim((string)($data['external_listing_id'] ?? $existing['external_listing_id'] ?? '')),
2182 'external_offer_id' => trim((string)($data['external_offer_id'] ?? $existing['external_offer_id'] ?? '')),
2183 'export_status' => (string)($existing['export_status'] ?? ''),
2184 'export_message' => (string)($existing['export_message'] ?? ''),
2185 'export_payload' => (string)($existing['export_payload'] ?? ''),
2186 'last_export_date' => (string)($existing['last_export_date'] ?? ''),
2187 'note' => $note,
2188 ),
2189 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
2190 0
2191 );
2192 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . $productId . ' AND trash = 0', 0);
2193 }
2194
2195 private function saveProductChannelExportResult(int $productId, string $channelKey, array $result): void {
2196 $payload = $result['payload'] ?? array();
2197 $payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
2198 if ($payloadJson === false) {
2199 $payloadJson = '';
2200 }
2201 $this->db()->save(
2202 $this->dd('shopProductChannel'),
2203 array(
2204 'product_id' => $productId,
2205 'channel_key' => $channelKey,
2206 'active' => 1,
2207 'external_listing_id' => (string)($result['external_listing_id'] ?? ''),
2208 'external_offer_id' => (string)($result['external_offer_id'] ?? ''),
2209 'export_status' => (string)($result['status'] ?? (!empty($result['ok']) ? 'exported' : 'failed')),
2210 'export_message' => (string)($result['message'] ?? ''),
2211 'export_payload' => $payloadJson,
2212 'last_export_date' => date('Y-m-d H:i:s'),
2213 ),
2214 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
2215 0
2216 );
2217 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . $productId . ' AND trash = 0', 0);
2218 }
2219
2220 public function exportProductToChannel(int $productId, string $channelKey): array {
2221 $this->install();
2222 $channelKey = trim($channelKey);
2223 $product = $this->productById($productId);
2224 if (!$product) {
2225 return array('ok' => false, 'status' => 'failed', 'message' => 'Artikel wurde nicht gefunden.');
2226 }
2227 $channel = $this->channelByKey($channelKey);
2228 if (!$channel || (int)($channel['active'] ?? 0) !== 1) {
2229 return array('ok' => false, 'status' => 'failed', 'message' => 'Channel ist nicht aktiv oder wurde nicht gefunden.');
2230 }
2231 if ((int)($channel['export_enabled'] ?? 0) !== 1) {
2232 return array('ok' => false, 'status' => 'failed', 'message' => 'Export ist fuer diesen Channel nicht aktiv.');
2233 }
2234 if (!$this->productHasActiveChannel($product, $channelKey)) {
2235 return array('ok' => false, 'status' => 'failed', 'message' => 'Artikel ist diesem Channel nicht aktiv zugeordnet.');
2236 }
2237
2238 $productChannel = $this->productChannelRowForExport($product, $channelKey);
2239 $connector = dbx()->get_include_obj('dbxShopChannelConnector', 'dbxShop');
2240 $result = is_object($connector) && method_exists($connector, 'exportProduct')
2241 ? (array)$connector->exportProduct($channel, $product, $productChannel)
2242 : array('ok' => false, 'status' => 'failed', 'message' => 'Channel-Export-Connector konnte nicht geladen werden.');
2243 $this->saveProductChannelExportResult((int)$product['id'], $channelKey, $result);
2244 return $result;
2245 }
2246
2247 public function exportProductsToChannel(array $ids, string $channelKey): array {
2248 $this->install();
2249 $ids = $this->normalizeProductIds($ids);
2250 $summary = array('total' => count($ids), 'ok' => 0, 'failed' => 0, 'messages' => array());
2251 foreach ($ids as $id) {
2252 $result = $this->exportProductToChannel($id, $channelKey);
2253 if (!empty($result['ok'])) {
2254 $summary['ok']++;
2255 } else {
2256 $summary['failed']++;
2257 }
2258 $message = trim((string)($result['message'] ?? ''));
2259 if ($message !== '') {
2260 $summary['messages'][] = '#' . $id . ': ' . $message;
2261 }
2262 }
2263 return $summary;
2264 }
2265
2266 private function saveChannelTestResult(int $id, bool $ok, string $message): void {
2267 $now = date('Y-m-d H:i:s');
2268 $this->db()->update(
2269 $this->dd('shopChannel'),
2270 array(
2271 'test_status' => $ok ? 'ok' : 'error',
2272 'test_message' => $message,
2273 'last_test_date' => $now,
2274 'update_date' => $now,
2275 ),
2276 'id = ' . (int)$id,
2277 0
2278 );
2279 }
2280
2281 public function shippingGroups(): array {
2282 $this->install();
2283 $rows = $this->db()->select($this->dd('shopShippingGroup'), 'trash = 0', '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
2284 return is_array($rows) ? $rows : array();
2285 }
2286
2287 public function channelGroups(): array {
2288 $this->install();
2289 $groups = $this->db()->select($this->dd('shopChannelGroup'), 'trash = 0', '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
2290 $groups = is_array($groups) ? $groups : array();
2291 foreach ($groups as &$group) {
2292 $group['channels'] = $this->channelsForChannelGroup((int)$group['id']);
2293 }
2294 unset($group);
2295 return $groups;
2296 }
2297
2298 public function channelsForChannelGroup(int $channelGroupId): array {
2299 $channelIndex = array();
2300 foreach ($this->channels() as $channel) {
2301 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
2302 }
2303 $rows = $this->db()->select($this->dd('shopChannelGroupChannel'), 'channel_group_id = ' . (int)$channelGroupId, '*', '', 'ASC', '', 0, 0, 0);
2304 $rows = is_array($rows) ? $rows : array();
2305 foreach ($rows as &$row) {
2306 $key = (string)($row['channel_key'] ?? '');
2307 $row['title'] = (string)($channelIndex[$key]['title'] ?? $key);
2308 $row['_sorter'] = (int)($channelIndex[$key]['sorter'] ?? 9999);
2309 }
2310 unset($row);
2311 usort($rows, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
2312 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
2313 foreach ($rows as &$row) {
2314 unset($row['_sorter']);
2315 }
2316 unset($row);
2317 return $rows;
2318 }
2319
2320 public function updateProductGroup(int $id, array $data): void {
2321 $this->install();
2322 $parentId = max(0, (int)($data['parent_id'] ?? 0));
2323 if ($id > 0 && $parentId === $id) {
2324 $parentId = 0;
2325 }
2326 $values = array(
2327 'parent_id' => $parentId,
2328 'title' => (string)($data['title'] ?? ''),
2329 'description' => (string)($data['description'] ?? ''),
2330 'tax_class' => (string)($data['tax_class'] ?? 'mwst1'),
2331 'default_tax_rate' => $this->taxRateForClass((string)($data['tax_class'] ?? 'mwst1'), (float)($data['default_tax_rate'] ?? 19)),
2332 'display_variant' => (string)($data['display_variant'] ?? 'gallery_grid'),
2333 'card_template' => (string)($data['card_template'] ?? 'product-card-default'),
2334 'detail_template' => (string)($data['detail_template'] ?? 'product-detail-default'),
2335 'gallery_template' => (string)($data['gallery_template'] ?? 'image-gallery'),
2336 'gallery_visible_count' => max(1, (int)($data['gallery_visible_count'] ?? 3)),
2337 'gallery_image_size' => (string)($data['gallery_image_size'] ?? 'original'),
2338 'gallery_lightbox_width' => (string)($data['gallery_lightbox_width'] ?? '100vw'),
2339 'gallery_overflow' => (string)($data['gallery_overflow'] ?? 'grid'),
2340 'gallery_click' => (string)($data['gallery_click'] ?? 'lightbox'),
2341 'attribute_notes' => (string)($data['attribute_notes'] ?? ''),
2342 'ebay_category_id' => (string)($data['ebay_category_id'] ?? ''),
2343 'amazon_product_type' => (string)($data['amazon_product_type'] ?? ''),
2344 'kleinanzeigen_category_id' => (string)($data['kleinanzeigen_category_id'] ?? ''),
2345 'mobile_category_id' => (string)($data['mobile_category_id'] ?? ''),
2346 'active' => !empty($data['active']) ? 1 : 0,
2347 'sorter' => (int)($data['sorter'] ?? 100),
2348 );
2349 if ($id <= 0) {
2350 $groupKey = $this->normalizeKey((string)($data['group_key'] ?? ''));
2351 if ($groupKey === '') {
2352 $groupKey = $this->normalizeKey((string)($data['title'] ?? 'artikelgruppe'));
2353 }
2354 if ($groupKey === '') $groupKey = 'artikelgruppe';
2355 $groupKey = $this->uniqueProductGroupKey($groupKey);
2356 $values['group_key'] = $groupKey;
2357 $this->db()->insert($this->dd('shopProductGroup'), $values, 0);
2358 $this->clearRequestCache();
2359 return;
2360 }
2361
2362 $this->db()->update($this->dd('shopProductGroup'), $values, 'id = ' . (int)$id, 0);
2363 $this->clearRequestCache();
2364 }
2365
2366 public function updateShippingGroup(int $id, array $data): void {
2367 $this->install();
2368 $values = array(
2369 'title' => (string)($data['title'] ?? ''),
2370 'description' => (string)($data['description'] ?? ''),
2371 'shipping_way' => (string)($data['shipping_way'] ?? ''),
2372 'delivery_time' => (string)($data['delivery_time'] ?? ''),
2373 'shipping_gross' => (float)($data['shipping_gross'] ?? 0),
2374 'free_from_gross' => (float)($data['free_from_gross'] ?? -1),
2375 'active' => !empty($data['active']) ? 1 : 0,
2376 'sorter' => (int)($data['sorter'] ?? 100),
2377 );
2378 if ($id <= 0) {
2379 $groupKey = $this->normalizeKey((string)($data['group_key'] ?? ''));
2380 if ($groupKey === '') {
2381 $groupKey = $this->normalizeKey((string)($data['title'] ?? 'versandgruppe'));
2382 }
2383 if ($groupKey === '') $groupKey = 'versandgruppe';
2384 $groupKey = $this->uniqueShippingGroupKey($groupKey);
2385 $values['group_key'] = $groupKey;
2386 $this->db()->insert($this->dd('shopShippingGroup'), $values, 0);
2387 return;
2388 }
2389
2390 $this->db()->update($this->dd('shopShippingGroup'), $values, 'id = ' . (int)$id, 0);
2391 }
2392
2393 public function updateChannelGroup(int $id, array $data, array $channelKeys): void {
2394 $this->install();
2395 $values = array(
2396 'title' => (string)($data['title'] ?? ''),
2397 'description' => (string)($data['description'] ?? ''),
2398 'active' => !empty($data['active']) ? 1 : 0,
2399 'sorter' => (int)($data['sorter'] ?? 100),
2400 );
2401 if ($id <= 0) {
2402 $groupKey = $this->normalizeKey((string)($data['group_key'] ?? ''));
2403 if ($groupKey === '') {
2404 $groupKey = $this->normalizeKey((string)($data['title'] ?? 'channel-gruppe'));
2405 }
2406 if ($groupKey === '') $groupKey = 'channel-gruppe';
2407 $groupKey = $this->uniqueChannelGroupKey($groupKey);
2408 $values['group_key'] = $groupKey;
2409 if ($values['title'] === '') {
2410 $values['title'] = $groupKey;
2411 }
2412 $this->db()->insert($this->dd('shopChannelGroup'), $values, 0);
2413 $row = $this->db()->select1($this->dd('shopChannelGroup'), 'group_key = ' . $this->sqlValue($groupKey), 'id', 0);
2414 $id = (int)($row['id'] ?? 0);
2415 } else {
2416 $this->db()->update($this->dd('shopChannelGroup'), $values, 'id = ' . (int)$id, 0);
2417 }
2418
2419 if ($id <= 0) {
2420 return;
2421 }
2422 $channels = $this->channels();
2423 foreach ($channels as $channel) {
2424 $key = (string)($channel['channel_key'] ?? '');
2425 if ($key === '') continue;
2426 $active = in_array($key, $channelKeys, true) ? 1 : 0;
2427 $this->db()->save(
2428 $this->dd('shopChannelGroupChannel'),
2429 array('channel_group_id' => $id, 'channel_key' => $key, 'active' => $active),
2430 'channel_group_id = ' . (int)$id . ' AND channel_key = ' . $this->sqlValue($key),
2431 0
2432 );
2433 }
2434 }
2435
2436 public function deleteChannelGroup(int $id): int {
2437 $this->install();
2438 if ($id <= 0) {
2439 return 0;
2440 }
2441
2442 $updated = (int)$this->db()->update(
2443 $this->dd('shopChannelGroup'),
2444 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
2445 'id = ' . (int)$id . ' AND trash = 0',
2446 0
2447 );
2448 if ($updated !== 1) {
2449 return 0;
2450 }
2451
2452 $this->db()->update($this->dd('shopChannelGroupChannel'), array('active' => 0), 'channel_group_id = ' . (int)$id, 0);
2453 return 1;
2454 }
2455
2456 public function deleteProductGroup(int $id): int {
2457 $this->install();
2458 if ($id <= 0) {
2459 return 0;
2460 }
2461
2462 $updated = (int)$this->db()->update(
2463 $this->dd('shopProductGroup'),
2464 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
2465 'id = ' . (int)$id . ' AND trash = 0',
2466 0
2467 );
2468 $this->clearRequestCache();
2469 return $updated;
2470 }
2471
2472 public function deleteShippingGroup(int $id): int {
2473 $this->install();
2474 if ($id <= 0) {
2475 return 0;
2476 }
2477
2478 return (int)$this->db()->update(
2479 $this->dd('shopShippingGroup'),
2480 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
2481 'id = ' . (int)$id . ' AND trash = 0',
2482 0
2483 );
2484 }
2485
2486 private function normalizeKey(string $value): string {
2487 $value = strtolower(trim($value));
2488 $value = preg_replace('~[^a-z0-9_-]+~', '-', $value);
2489 $value = trim((string)$value, '-_');
2490 return $value;
2491 }
2492
2493 private function uniqueProductGroupKey(string $baseKey): string {
2494 return $this->uniqueKey('shop_product_group', $baseKey);
2495 }
2496
2497 private function uniqueShippingGroupKey(string $baseKey): string {
2498 return $this->uniqueKey('shop_shipping_group', $baseKey);
2499 }
2500
2501 private function uniqueChannelKey(string $baseKey): string {
2502 $key = $baseKey !== '' ? $baseKey : 'channel';
2503 $suffix = 2;
2504 while (true) {
2505 if ($this->db()->count($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($key)) <= 0) return $key;
2506 $key = $baseKey . '-' . $suffix;
2507 $suffix++;
2508 }
2509 }
2510
2511 private function uniqueChannelGroupKey(string $baseKey): string {
2512 return $this->uniqueKey('shop_channel_group', $baseKey);
2513 }
2514
2515 private function uniqueKey(string $table, string $baseKey): string {
2516 $ddMap = array(
2517 'shop_product_group' => $this->dd('shopProductGroup'),
2518 'shop_shipping_group' => $this->dd('shopShippingGroup'),
2519 'shop_channel_group' => $this->dd('shopChannelGroup'),
2520 );
2521 if (!isset($ddMap[$table])) {
2522 return $baseKey;
2523 }
2524 $key = $baseKey !== '' ? $baseKey : 'gruppe';
2525 $suffix = 2;
2526 while (true) {
2527 if ($this->db()->count($ddMap[$table], 'group_key = ' . $this->sqlValue($key)) <= 0) return $key;
2528 $key = $baseKey . '-' . $suffix;
2529 $suffix++;
2530 }
2531 }
2532
2533 public function imagesForProduct(int $productId, array $groups = array()): array {
2534 $this->install();
2535 $images = array();
2536 if ($productId > 0) {
2537 $rows = $this->db()->select(
2538 $this->dd('shopProductImage'),
2539 'trash = 0 AND active = 1 AND product_id = ' . (int)$productId,
2540 '*',
2541 'is_primary DESC, sorter ASC, title ASC',
2542 'ASC',
2543 '',
2544 0,
2545 0,
2546 0
2547 );
2548 $images = is_array($rows) ? $rows : array();
2549 }
2550
2551 $groupIds = array();
2552 foreach ($groups as $group) {
2553 $id = (int)($group['id'] ?? 0);
2554 if ($id > 0) $groupIds[] = $id;
2555 }
2556 if ($groupIds !== array()) {
2557 $rows = $this->db()->select(
2558 $this->dd('shopProductImage'),
2559 'trash = 0 AND active = 1 AND group_id IN (' . implode(',', array_map('intval', $groupIds)) . ')',
2560 '*',
2561 'is_primary DESC, sorter ASC, title ASC',
2562 'ASC',
2563 '',
2564 0,
2565 0,
2566 0
2567 );
2568 $images = array_merge($images, is_array($rows) ? $rows : array());
2569 }
2570
2571 $seen = array();
2572 $clean = array();
2573 foreach ($images as $image) {
2574 $path = (string)($image['image_path'] ?? '');
2575 $mediaId = (int)($image['media_id'] ?? 0);
2576 $key = $mediaId > 0 ? 'm:' . $mediaId : 'p:' . $path;
2577 if (($mediaId <= 0 && $path === '') || isset($seen[$key])) continue;
2578 $seen[$key] = true;
2579 $clean[] = $image;
2580 }
2581 return $clean;
2582 }
2583
2584 public function productImageCounts(): array {
2585 $this->install();
2586 $counts = array();
2587 $products = $this->db()->select($this->dd('shopProduct'), 'trash = 0', 'id', '', 'ASC', '', 0, 0, 0);
2588 foreach ((is_array($products) ? $products : array()) as $product) {
2589 $productId = (int)($product['id'] ?? 0);
2590 if ($productId <= 0) continue;
2591 $groups = $this->groupsForProduct($productId);
2592 $counts[$productId] = count($this->imagesForProduct($productId, $groups));
2593 }
2594 return $counts;
2595 }
2596
2597 public function allImages(): array {
2598 $this->install();
2599 $rows = $this->db()->select($this->dd('shopProductImage'), 'trash = 0', '*', 'active DESC, product_id DESC, group_id DESC, sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
2600 $rows = is_array($rows) ? $rows : array();
2601
2602 $productIds = array_values(array_unique(array_filter(array_map(
2603 static fn($row) => (int)($row['product_id'] ?? 0),
2604 $rows
2605 ))));
2606 $groupIds = array_values(array_unique(array_filter(array_map(
2607 static fn($row) => (int)($row['group_id'] ?? 0),
2608 $rows
2609 ))));
2610 $productById = array();
2611 if ($productIds !== array()) {
2612 $products = $this->db()->select(
2613 $this->dd('shopProduct'),
2614 'id IN (' . implode(',', array_map('intval', $productIds)) . ')',
2615 'id,title',
2616 '',
2617 'ASC',
2618 '',
2619 0,
2620 0,
2621 0
2622 );
2623 $productById = $this->rowsById(is_array($products) ? $products : array());
2624 }
2625 $groupById = array();
2626 if ($groupIds !== array()) {
2627 $groups = $this->db()->select(
2628 $this->dd('shopProductGroup'),
2629 'id IN (' . implode(',', array_map('intval', $groupIds)) . ')',
2630 'id,title',
2631 '',
2632 'ASC',
2633 '',
2634 0,
2635 0,
2636 0
2637 );
2638 $groupById = $this->rowsById(is_array($groups) ? $groups : array());
2639 }
2640
2641 foreach ($rows as &$row) {
2642 $productId = (int)($row['product_id'] ?? 0);
2643 $groupId = (int)($row['group_id'] ?? 0);
2644 $row['product_title'] = (string)($productById[$productId]['title'] ?? '');
2645 $row['group_title'] = (string)($groupById[$groupId]['title'] ?? '');
2646 }
2647 unset($row);
2648 return $rows;
2649 }
2650
2651 public function updateImageMediaReference(int $imageId, int $mediaId, string $imagePath = ''): void {
2652 $this->install();
2653 $imageId = max(0, $imageId);
2654 $mediaId = max(0, $mediaId);
2655 if ($imageId <= 0 || $mediaId <= 0) {
2656 return;
2657 }
2658
2659 $imagePath = trim(str_replace('\\', '/', $imagePath));
2660 if ($imagePath === '') {
2661 $imagePath = 'dbxmedia:' . $mediaId;
2662 }
2663
2664 $this->db()->update(
2665 $this->dd('shopProductImage'),
2666 array('media_id' => $mediaId, 'image_path' => $imagePath),
2667 'id = ' . (int)$imageId,
2668 0
2669 );
2670 }
2671
2672 public function saveImage(int $productId, int $groupId, string $imagePath, string $title, string $alt, int $isPrimary = 0, int $sorter = 100): void {
2673 $this->install();
2674 $productId = max(0, $productId);
2675 $groupId = max(0, $groupId);
2676 $imagePath = trim(str_replace('\\', '/', $imagePath));
2677 if ($imagePath === '' || ($productId <= 0 && $groupId <= 0)) {
2678 return;
2679 }
2680 if ($groupId > 0 && $productId <= 0) {
2681 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), 'group_id = ' . (int)$groupId . ' AND product_id = 0 AND trash = 0', 0);
2682 $isPrimary = 1;
2683 }
2684 $this->db()->save(
2685 $this->dd('shopProductImage'),
2686 array(
2687 'product_id' => $productId,
2688 'group_id' => $groupId,
2689 'image_path' => $imagePath,
2690 'title' => $title,
2691 'alt' => $alt,
2692 'is_primary' => $isPrimary,
2693 'active' => 1,
2694 'sorter' => $sorter,
2695 ),
2696 'product_id = ' . (int)$productId . ' AND group_id = ' . (int)$groupId . ' AND image_path = ' . $this->sqlValue($imagePath),
2697 0
2698 );
2699 }
2700
2701 public function saveMediaImage(int $productId, int $groupId, int $mediaId, string $title = '', string $alt = '', int $isPrimary = 0, int $sorter = 100): ?array {
2702 $this->install();
2703 $productId = max(0, $productId);
2704 $groupId = max(0, $groupId);
2705 $mediaId = max(0, $mediaId);
2706 if ($mediaId <= 0 || ($productId <= 0 && $groupId <= 0)) {
2707 return null;
2708 }
2709 if ($groupId > 0 && $productId <= 0) {
2710 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), 'group_id = ' . (int)$groupId . ' AND product_id = 0 AND trash = 0', 0);
2711 $isPrimary = 1;
2712 }
2713
2714 $pathKey = 'dbxmedia:' . $mediaId;
2715 $this->db()->save(
2716 $this->dd('shopProductImage'),
2717 array(
2718 'product_id' => $productId,
2719 'group_id' => $groupId,
2720 'media_id' => $mediaId,
2721 'image_path' => $pathKey,
2722 'title' => $title,
2723 'alt' => $alt,
2724 'is_primary' => $isPrimary,
2725 'active' => 1,
2726 'sorter' => $sorter,
2727 ),
2728 'product_id = ' . (int)$productId . ' AND group_id = ' . (int)$groupId . ' AND image_path = ' . $this->sqlValue($pathKey),
2729 0
2730 );
2731
2732 $data = $this->db()->select1(
2733 $this->dd('shopProductImage'),
2734 'product_id = ' . (int)$productId . ' AND group_id = ' . (int)$groupId . ' AND image_path = ' . $this->sqlValue($pathKey),
2735 '*',
2736 0
2737 );
2738 return is_array($data) ? $data : null;
2739 }
2740
2741 public function primaryImageForGroup(int $groupId): ?array {
2742 $this->install();
2743 if ($groupId <= 0) {
2744 return null;
2745 }
2746 $rows = $this->db()->select(
2747 $this->dd('shopProductImage'),
2748 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . (int)$groupId,
2749 '*',
2750 'is_primary DESC, sorter ASC, title ASC',
2751 'ASC',
2752 '',
2753 0,
2754 1,
2755 0
2756 );
2757 $row = is_array($rows) && isset($rows[0]) ? $rows[0] : array();
2758 return is_array($row) ? $row : null;
2759 }
2760
2761 public function removeProductImageAssociation(int $imageId, int $productId = 0): bool {
2762 $this->install();
2763 $imageId = max(0, $imageId);
2764 $productId = max(0, $productId);
2765 if ($imageId <= 0) {
2766 return false;
2767 }
2768
2769 $where = 'id = ' . (int)$imageId;
2770 if ($productId > 0) {
2771 $groupMaps = $this->db()->select($this->dd('shopProductGroupMap'), 'product_id = ' . (int)$productId, 'group_id', '', 'ASC', '', 0, 0, 0);
2772 $groupIds = array();
2773 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
2774 $groupId = (int)($groupMap['group_id'] ?? 0);
2775 if ($groupId > 0) $groupIds[$groupId] = $groupId;
2776 }
2777 $parts = array('product_id = ' . (int)$productId);
2778 if ($groupIds !== array()) {
2779 $parts[] = 'group_id IN (' . implode(',', array_map('intval', $groupIds)) . ')';
2780 }
2781 $where .= ' AND (' . implode(' OR ', $parts) . ')';
2782 }
2783
2784 if ($this->db()->count($this->dd('shopProductImage'), $where) <= 0) {
2785 return false;
2786 }
2787 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), $where, 0);
2788 return true;
2789 }
2790
2791 public function createOrderFromItems(array $items, string $channelKey = 'shop', string $customerName = '', string $customerEmail = '', string $note = '', string $paymentProvider = '', string $paymentStatus = 'open', string $status = 'payment_pending', string $customerPhone = '', string $shippingAddress = '', string $legalSnapshot = '', string $withdrawalSnapshot = ''): ?array {
2792 $this->install();
2793 if ($items === array()) {
2794 return null;
2795 }
2796
2797 $now = date('Y-m-d H:i:s');
2798 $orderNo = 'S' . date('YmdHis') . '-' . random_int(1000, 9999);
2799 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
2800 $allowedStatus = array('new', 'payment_pending', 'paid', 'processing', 'shipped', 'done', 'cancelled');
2801 $allowedPayment = array('open', 'created', 'pending', 'completed', 'paid', 'failed', 'cancelled', 'refunded');
2802 if (!in_array($status, $allowedStatus, true)) {
2803 $status = 'payment_pending';
2804 }
2805 if (!in_array($paymentStatus, $allowedPayment, true)) {
2806 $paymentStatus = 'open';
2807 }
2808 $total = 0.0;
2809 $snapshots = array();
2810
2811 foreach ($items as $sku => $qty) {
2812 $qty = max(1, (int)$qty);
2813 $product = $this->productBySku((string)$sku);
2814 if (!$product) continue;
2815 $price = (float)($product['price_gross'] ?? 0);
2816 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
2817 $line = ($price + $shipping) * $qty;
2818 $total += $line;
2819 $snapshots[] = array(
2820 'product_id' => (int)($product['id'] ?? 0),
2821 'product_type' => (string)($product['product_type'] ?? ''),
2822 'sku' => (string)($product['sku'] ?? $sku),
2823 'title' => (string)($product['title'] ?? ''),
2824 'qty' => $qty,
2825 'price_gross' => $price,
2826 'tax_rate' => (float)($product['effective_tax_rate'] ?? 0),
2827 'shipping_gross' => $shipping,
2828 'total_gross' => $line,
2829 );
2830 }
2831
2832 if ($snapshots === array()) {
2833 return null;
2834 }
2835 $stockReserved = $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2836
2837 $db = $this->db();
2838 if ($db->begin($this->dd('shopOrder')) !== 1) {
2839 throw new \RuntimeException('Bestelltransaktion konnte nicht gestartet werden.');
2840 }
2841
2842 try {
2843 $orderOk = (int)$db->insert($this->dd('shopOrder'), array(
2844 'create_date' => $now,
2845 'update_date' => $now,
2846 'order_no' => $orderNo,
2847 'uid' => $uid,
2848 'status' => $status,
2849 'customer_name' => $customerName,
2850 'customer_email' => $customerEmail,
2851 'customer_phone' => $customerPhone,
2852 'shipping_address' => $shippingAddress,
2853 'total_gross' => $total,
2854 'currency' => 'EUR',
2855 'channel_key' => $channelKey,
2856 'payment_provider' => $paymentProvider,
2857 'payment_status' => $paymentStatus,
2858 'stock_reserved' => $stockReserved,
2859 'legal_snapshot' => $legalSnapshot,
2860 'withdrawal_snapshot' => $withdrawalSnapshot,
2861 'note' => $note,
2862 ), 0);
2863 if ($orderOk !== 1) {
2864 throw new \RuntimeException('order_insert_failed');
2865 }
2866 $orderId = (int)$db->get_insert_id();
2867 if ($orderId <= 0) {
2868 throw new \RuntimeException('order_id_missing');
2869 }
2870
2871 foreach ($snapshots as $item) {
2872 if ($db->insert($this->dd('shopOrderItem'), array(
2873 'create_date' => $now,
2874 'update_date' => $now,
2875 'order_id' => $orderId,
2876 'product_id' => $item['product_id'],
2877 'sku' => $item['sku'],
2878 'title' => $item['title'],
2879 'qty' => $item['qty'],
2880 'price_gross' => $item['price_gross'],
2881 'tax_rate' => $item['tax_rate'],
2882 'shipping_gross' => $item['shipping_gross'],
2883 'total_gross' => $item['total_gross'],
2884 ), 0) !== 1) {
2885 throw new \RuntimeException('order_item_insert_failed');
2886 }
2887 }
2888
2889 $reserved = $stockReserved === 1 ? $this->reserveStockForSnapshots($snapshots) : 0;
2890 if ($stockReserved === 1 && $reserved <= 0) {
2891 throw new \RuntimeException('stock_reservation_failed');
2892 }
2893 if (!$this->addOrderHistory($orderId, 'created', '', $status, 'Bestellung wurde angelegt.')) {
2894 throw new \RuntimeException('order_history_insert_failed');
2895 }
2896 if ($db->commit($this->dd('shopOrder')) !== 1) {
2897 throw new \RuntimeException('order_commit_failed');
2898 }
2899 } catch (\Throwable $e) {
2900 $db->rollback($this->dd('shopOrder'));
2901 if (str_starts_with($e->getMessage(), 'Nicht genuegend Lagerbestand')) {
2902 throw $e;
2903 }
2904 dbx()->debug('#Shop order rollback error=(' . $e->getMessage() . ')');
2905 return null;
2906 }
2907
2908 return $this->orderByNo($orderNo);
2909 }
2910
2911 public function importChannelOrder(string $channelKey, array $payload): ?array {
2912 $this->install();
2913 $channel = $this->channelByKey($channelKey);
2914 if (!$channel || (int)($channel['order_import_enabled'] ?? 0) !== 1 || (int)($channel['active'] ?? 0) !== 1) {
2915 throw new \RuntimeException('Order-Import fuer diesen Channel ist nicht aktiv.');
2916 }
2917
2918 $externalId = trim((string)($payload['order_id'] ?? $payload['external_order_id'] ?? $payload['id'] ?? ''));
2919 if ($externalId === '') {
2920 throw new \RuntimeException('Payload enthaelt keine externe Bestellnummer.');
2921 }
2922
2923 $paymentStatus = strtolower((string)($payload['payment_status'] ?? $payload['status'] ?? 'completed'));
2924 $normalizedPayment = in_array($paymentStatus, array('paid', 'completed', 'captured'), true)
2925 ? 'completed'
2926 : (in_array($paymentStatus, array('cancelled', 'canceled', 'voided'), true) ? 'cancelled' : 'pending');
2927
2928 $existing = $this->orderByPaymentReference($channelKey, $externalId);
2929 if ($existing) {
2930 $this->updateOrderPayment((int)$existing['id'], $channelKey, $normalizedPayment, $externalId, $payload);
2931 return $this->orderByNo((string)$existing['order_no']);
2932 }
2933
2934 $items = is_array($payload['items'] ?? null) ? $payload['items'] : array();
2935 if ($items === array()) {
2936 throw new \RuntimeException('Payload enthaelt keine Positionen.');
2937 }
2938
2939 $now = date('Y-m-d H:i:s');
2940 $orderNo = 'C' . date('YmdHis') . '-' . random_int(1000, 9999);
2941 $customer = is_array($payload['customer'] ?? null) ? $payload['customer'] : array();
2942 $customerName = (string)($payload['customer_name'] ?? $customer['name'] ?? '');
2943 $customerEmail = (string)($payload['customer_email'] ?? $customer['email'] ?? '');
2944 $customerPhone = (string)($payload['customer_phone'] ?? $customer['phone'] ?? '');
2945 $shipping = is_array($payload['shipping_address'] ?? null) ? $payload['shipping_address'] : (is_array($payload['shipping'] ?? null) ? $payload['shipping'] : array());
2946 $shippingAddress = trim((string)($payload['shipping_address_text'] ?? $payload['address'] ?? ''));
2947 if ($shippingAddress === '' && $shipping !== array()) {
2948 $shippingAddress = trim(implode("\n", array_filter(array_map('strval', array(
2949 $shipping['name'] ?? $customerName,
2950 $shipping['street'] ?? $shipping['address1'] ?? '',
2951 $shipping['address2'] ?? '',
2952 trim((string)($shipping['zip'] ?? $shipping['postal_code'] ?? '') . ' ' . (string)($shipping['city'] ?? '')),
2953 $shipping['country'] ?? $shipping['country_code'] ?? '',
2954 )))));
2955 }
2956 $currency = (string)($payload['currency'] ?? 'EUR');
2957 $snapshots = array();
2958 $total = 0.0;
2959
2960 foreach ($items as $item) {
2961 if (!is_array($item)) {
2962 continue;
2963 }
2964 $sku = (string)($item['sku'] ?? $item['seller_sku'] ?? $item['item_sku'] ?? '');
2965 $qty = max(1, (int)($item['qty'] ?? $item['quantity'] ?? 1));
2966 $product = $sku !== '' ? $this->productBySku($sku, false) : null;
2967 $price = (float)($item['price_gross'] ?? $item['price'] ?? $item['unit_price'] ?? ($product['price_gross'] ?? 0));
2968 $shipping = (float)($item['shipping_gross'] ?? $item['shipping'] ?? 0);
2969 $lineTotal = (float)($item['total_gross'] ?? $item['total'] ?? (($price + $shipping) * $qty));
2970 $total += $lineTotal;
2971 $snapshots[] = array(
2972 'product_id' => (int)($product['id'] ?? 0),
2973 'product_type' => (string)($product['product_type'] ?? ''),
2974 'sku' => $sku,
2975 'title' => (string)($item['title'] ?? $item['name'] ?? $product['title'] ?? $sku),
2976 'qty' => $qty,
2977 'price_gross' => $price,
2978 'tax_rate' => (float)($item['tax_rate'] ?? $product['effective_tax_rate'] ?? 0),
2979 'shipping_gross' => $shipping,
2980 'total_gross' => $lineTotal,
2981 );
2982 }
2983
2984 if ($snapshots === array()) {
2985 throw new \RuntimeException('Payload enthaelt keine verwertbaren Positionen.');
2986 }
2987 if (isset($payload['total_gross']) || isset($payload['total']) || isset($payload['amount'])) {
2988 $total = (float)($payload['total_gross'] ?? $payload['total'] ?? $payload['amount']);
2989 }
2990 $stockReserved = $normalizedPayment !== 'cancelled' && $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2991
2992 $db = $this->db();
2993 if ($db->begin($this->dd('shopOrder')) !== 1) {
2994 throw new \RuntimeException('Channel-Bestelltransaktion konnte nicht gestartet werden.');
2995 }
2996
2997 try {
2998 // Ein no-op-Update auf dem Channel erzeugt auf den unterstuetzten
2999 // relationalen Datenbanken einen Zeilen-Lock bis zum Commit. Damit
3000 // werden gleiche Providerreferenzen auch ausserhalb von SQLite und
3001 // ueber mehrere App-Prozesse pro Channel serialisiert.
3002 $channelId = (int)($channel['id'] ?? 0);
3003 $channelDd = $this->dd('shopChannel');
3004 $channelServer = $db->get_dd_server($channelDd);
3005 $channelTable = $db->get_dd_table($channelDd);
3006 $lockResult = $channelId > 0
3007 ? $db->update_query(
3008 $channelServer,
3009 'UPDATE ' . $channelTable . ' SET id = id WHERE id = ' . $channelId . ' AND trash = 0'
3010 )
3011 : -2;
3012 $lockedChannel = $lockResult >= 0
3013 ? $db->select1($channelDd, 'id = ' . $channelId . ' AND trash = 0', 'id', 0)
3014 : array();
3015 if (!is_array($lockedChannel) || (int)($lockedChannel['id'] ?? 0) !== $channelId) {
3016 throw new \RuntimeException('channel_import_lock_failed');
3017 }
3018
3019 // Zweite Idempotenzpruefung nach dem serialisierenden Channel-Lock.
3020 $duplicate = $db->select1(
3021 $this->dd('shopOrder'),
3022 'payment_provider = ' . $this->sqlValue($channelKey)
3023 . ' AND payment_reference = ' . $this->sqlValue($externalId)
3024 . ' AND trash = 0',
3025 'id,order_no',
3026 0
3027 );
3028 if (is_array($duplicate) && (int)($duplicate['id'] ?? 0) > 0) {
3029 $db->rollback($this->dd('shopOrder'));
3030 return $this->orderByNo((string)$duplicate['order_no']);
3031 }
3032
3033 if ($db->insert($this->dd('shopOrder'), array(
3034 'create_date' => $now,
3035 'update_date' => $now,
3036 'order_no' => $orderNo,
3037 'uid' => 0,
3038 'status' => $normalizedPayment === 'completed' ? 'paid' : ($normalizedPayment === 'cancelled' ? 'cancelled' : 'payment_pending'),
3039 'customer_name' => $customerName,
3040 'customer_email' => $customerEmail,
3041 'customer_phone' => $customerPhone,
3042 'shipping_address' => $shippingAddress,
3043 'total_gross' => $total,
3044 'currency' => $currency,
3045 'channel_key' => $channelKey,
3046 'payment_provider' => $channelKey,
3047 'payment_status' => $normalizedPayment,
3048 'stock_reserved' => $stockReserved,
3049 'payment_reference' => $externalId,
3050 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
3051 ), 0) !== 1) {
3052 throw new \RuntimeException('channel_order_insert_failed');
3053 }
3054 $orderId = (int)$db->get_insert_id();
3055 if ($orderId <= 0) throw new \RuntimeException('channel_order_id_missing');
3056
3057 foreach ($snapshots as $item) {
3058 if ($db->insert($this->dd('shopOrderItem'), array(
3059 'create_date' => $now,
3060 'update_date' => $now,
3061 'order_id' => $orderId,
3062 'product_id' => $item['product_id'],
3063 'sku' => $item['sku'],
3064 'title' => $item['title'],
3065 'qty' => $item['qty'],
3066 'price_gross' => $item['price_gross'],
3067 'tax_rate' => $item['tax_rate'],
3068 'shipping_gross' => $item['shipping_gross'],
3069 'total_gross' => $item['total_gross'],
3070 ), 0) !== 1) {
3071 throw new \RuntimeException('channel_order_item_insert_failed');
3072 }
3073 }
3074 $reserved = $stockReserved === 1 ? $this->reserveStockForSnapshots($snapshots) : 0;
3075 if ($stockReserved === 1 && $reserved <= 0) {
3076 throw new \RuntimeException('channel_stock_reservation_failed');
3077 }
3078 if (!$this->addOrderHistory($orderId, 'channel_import', '', $normalizedPayment, 'Bestellung wurde ueber Channel ' . $channelKey . ' importiert.')) {
3079 throw new \RuntimeException('channel_order_history_failed');
3080 }
3081 if ($db->commit($this->dd('shopOrder')) !== 1) {
3082 throw new \RuntimeException('channel_order_commit_failed');
3083 }
3084 } catch (\Throwable $e) {
3085 $db->rollback($this->dd('shopOrder'));
3086 dbx()->debug('#Shop channel order rollback reference=(' . $externalId . ') error=(' . $e->getMessage() . ')');
3087 throw $e;
3088 }
3089
3090 return $this->orderByNo($orderNo);
3091 }
3092
3093 public function orderByNo(string $orderNo): ?array {
3094 $this->install();
3095 $row = $this->db()->select1($this->dd('shopOrder'), 'order_no = ' . $this->sqlValue($orderNo) . ' AND trash = 0', '*', 0);
3096 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) return null;
3097 $row['items'] = $this->orderItems((int)$row['id']);
3098 $row['history'] = $this->orderHistory((int)$row['id']);
3099 $row['withdrawals'] = $this->withdrawalsForOrder((int)$row['id']);
3100 return $row;
3101 }
3102
3103 public function orderById(int $id): ?array {
3104 $this->install();
3105 $row = $this->db()->select1($this->dd('shopOrder'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
3106 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) return null;
3107 $row['items'] = $this->orderItems((int)$row['id']);
3108 $row['history'] = $this->orderHistory((int)$row['id']);
3109 $row['withdrawals'] = $this->withdrawalsForOrder((int)$row['id']);
3110 return $row;
3111 }
3112
3113 public function ordersByUid(int $uid, int $limit = 25): array {
3114 $this->install();
3115 if ($uid <= 0) {
3116 return array();
3117 }
3118 $rows = $this->db()->select($this->dd('shopOrder'), 'uid = ' . (int)$uid . ' AND trash = 0', '*', 'create_date DESC, id DESC', 'ASC', '', max(1, min(100, $limit)), 0, 0);
3119 $rows = is_array($rows) ? $rows : array();
3120 foreach ($rows as &$row) {
3121 $orderId = (int)($row['id'] ?? 0);
3122 $row['items'] = $this->orderItems($orderId);
3123 $row['history'] = $this->orderHistory($orderId);
3124 $row['withdrawals'] = $this->withdrawalsForOrder($orderId);
3125 }
3126 unset($row);
3127 return $rows;
3128 }
3129
3130 public function orders(array $filters = array(), int $limit = 50, int $offset = 0, string $sort = 'create_date', string $direction = 'DESC'): array {
3131 $this->install();
3132 $where = array('trash = 0');
3133
3134 $query = trim((string)($filters['query'] ?? ''));
3135 if ($query !== '') {
3136 $like = $this->sqlLikeValue($query);
3137 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ' OR channel_key LIKE ' . $like . ' OR payment_reference LIKE ' . $like . ')';
3138 }
3139 foreach (array('status', 'payment_status', 'shipping_status', 'channel_key') as $field) {
3140 $value = trim((string)($filters[$field] ?? ''));
3141 if ($value !== '') {
3142 $where[] = $field . ' = ' . $this->sqlValue($value);
3143 }
3144 }
3145
3146 $allowedSort = array('create_date', 'order_no', 'status', 'payment_status', 'shipping_status', 'customer_name', 'total_gross', 'channel_key');
3147 if (!in_array($sort, $allowedSort, true)) {
3148 $sort = 'create_date';
3149 }
3150 $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
3151 $max = $limit > 0 ? max(1, $limit) : 0;
3152 $rows = $this->db()->select($this->dd('shopOrder'), implode(' AND ', $where), '*', $sort . ' ' . $direction . ', id DESC', 'ASC', '', $max, max(0, $offset), 0);
3153 $rows = is_array($rows) ? $rows : array();
3154 foreach ($rows as &$row) {
3155 $row['items'] = $this->orderItems((int)$row['id']);
3156 }
3157 unset($row);
3158 return $rows;
3159 }
3160
3161 public function orderCount(array $filters = array()): int {
3162 $this->install();
3163 $where = array('trash = 0');
3164 $query = trim((string)($filters['query'] ?? ''));
3165 if ($query !== '') {
3166 $like = $this->sqlLikeValue($query);
3167 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ' OR channel_key LIKE ' . $like . ' OR payment_reference LIKE ' . $like . ')';
3168 }
3169 foreach (array('status', 'payment_status', 'shipping_status', 'channel_key') as $field) {
3170 $value = trim((string)($filters[$field] ?? ''));
3171 if ($value !== '') {
3172 $where[] = $field . ' = ' . $this->sqlValue($value);
3173 }
3174 }
3175 return max(0, (int)$this->db()->count($this->dd('shopOrder'), implode(' AND ', $where)));
3176 }
3177
3178 public function orderChannelKeys(): array {
3179 $this->install();
3180 $rows = $this->db()->select($this->dd('shopOrder'), "trash = 0 AND channel_key <> ''", 'channel_key', 'channel_key ASC', 'ASC', '', 0, 0, 0);
3181 $keys = array();
3182 foreach (is_array($rows) ? $rows : array() as $row) {
3183 $key = (string)($row['channel_key'] ?? '');
3184 if ($key !== '') {
3185 $keys[$key] = $key;
3186 }
3187 }
3188 ksort($keys);
3189 return array_values($keys);
3190 }
3191
3192 public function updateOrderAdmin(int $id, array $data): bool {
3193 $this->install();
3194 $allowedStatus = array('new', 'payment_pending', 'paid', 'processing', 'shipped', 'done', 'cancelled');
3195 $allowedPayment = array('open', 'created', 'pending', 'completed', 'paid', 'failed', 'cancelled', 'refunded');
3196 $allowedShipping = array('open', 'ready', 'shipped', 'delivered', 'returned');
3197 $before = $this->orderById($id);
3198 $status = (string)($data['status'] ?? 'new');
3199 $paymentStatus = (string)($data['payment_status'] ?? 'open');
3200 $shippingStatus = (string)($data['shipping_status'] ?? ($before['shipping_status'] ?? 'open'));
3201 if (!in_array($status, $allowedStatus, true)) $status = 'new';
3202 if (!in_array($paymentStatus, $allowedPayment, true)) $paymentStatus = 'open';
3203 if (!in_array($shippingStatus, $allowedShipping, true)) $shippingStatus = 'open';
3204 $invoiceNo = trim((string)($data['invoice_no'] ?? ($before['invoice_no'] ?? '')));
3205 $invoiceDate = trim((string)($data['invoice_date'] ?? ($before['invoice_date'] ?? '')));
3206 if ($invoiceNo === '' && in_array($status, array('paid', 'processing', 'shipped', 'done'), true)) {
3207 $invoiceNo = $this->nextInvoiceNo();
3208 $invoiceDate = date('Y-m-d');
3209 }
3210 $shippedDate = trim((string)($data['shipped_date'] ?? ($before['shipped_date'] ?? '')));
3211 if ($shippingStatus === 'shipped' && $shippedDate === '') {
3212 $shippedDate = date('Y-m-d H:i:s');
3213 }
3214 $shippingProvider = trim((string)($data['shipping_provider'] ?? ''));
3215 $trackingNo = trim((string)($data['tracking_no'] ?? ''));
3216 $trackingUrl = trim((string)($data['tracking_url'] ?? ''));
3217 if ($trackingUrl === '' && $trackingNo !== '') {
3218 $trackingUrl = $this->trackingUrlForProvider($shippingProvider, $trackingNo);
3219 }
3220
3221 $ok = $this->db()->update($this->dd('shopOrder'), array(
3222 'update_date' => date('Y-m-d H:i:s'),
3223 'status' => $status,
3224 'payment_status' => $paymentStatus,
3225 'payment_reference' => (string)($data['payment_reference'] ?? ''),
3226 'invoice_no' => $invoiceNo,
3227 'invoice_date' => $invoiceDate,
3228 'shipping_status' => $shippingStatus,
3229 'shipping_provider' => $shippingProvider,
3230 'tracking_no' => $trackingNo,
3231 'tracking_url' => $trackingUrl,
3232 'shipped_date' => $shippedDate,
3233 'note' => (string)($data['note'] ?? ''),
3234 ), 'id = ' . (int)$id . ' AND trash = 0', 0);
3235 if (is_array($before)) {
3236 foreach (array(
3237 'status' => $status,
3238 'payment_status' => $paymentStatus,
3239 'shipping_status' => $shippingStatus,
3240 'invoice_no' => $invoiceNo,
3241 'tracking_no' => $trackingNo,
3242 ) as $field => $newValue) {
3243 $oldValue = (string)($before[$field] ?? '');
3244 if ($oldValue !== (string)$newValue) {
3245 $this->addOrderHistory($id, $field, $oldValue, (string)$newValue, 'Admin-Aenderung');
3246 }
3247 }
3248 if ($status === 'cancelled' || in_array($paymentStatus, array('cancelled', 'refunded'), true) || $shippingStatus === 'returned') {
3249 $fresh = $this->orderById($id);
3250 if (is_array($fresh)) {
3251 $this->releaseStockForOrder($fresh, 'Bestand wurde durch Statusaenderung zurueckgebucht.');
3252 }
3253 }
3254 }
3255 return $ok !== 0 || $this->orderById($id) !== null;
3256 }
3257
3258 private function trackingUrlForProvider(string $provider, string $trackingNo): string {
3259 $trackingNo = trim($trackingNo);
3260 if ($trackingNo === '') {
3261 return '';
3262 }
3263 $providerKey = strtolower(trim($provider));
3264 if (str_contains($providerKey, 'dhl')) {
3265 return 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=' . rawurlencode($trackingNo);
3266 }
3267 if (str_contains($providerKey, 'ups')) {
3268 return 'https://www.ups.com/track?tracknum=' . rawurlencode($trackingNo);
3269 }
3270 if (str_contains($providerKey, 'dpd')) {
3271 return 'https://tracking.dpd.de/status/de_DE/parcel/' . rawurlencode($trackingNo);
3272 }
3273 if (str_contains($providerKey, 'hermes')) {
3274 return 'https://www.myhermes.de/empfangen/sendungsverfolgung/?su=' . rawurlencode($trackingNo);
3275 }
3276 return '';
3277 }
3278
3279 public function updateOrderQuickAction(int $id, string $action): array {
3280 $this->install();
3281 $order = $this->orderById($id);
3282 if (!is_array($order)) {
3283 return array(false, 'Bestellung nicht gefunden.');
3284 }
3285
3286 $data = $order;
3287 $message = '';
3288 switch ($action) {
3289 case 'mark_paid':
3290 $data['payment_status'] = 'paid';
3291 if (in_array((string)($data['status'] ?? ''), array('new', 'payment_pending'), true)) {
3292 $data['status'] = 'paid';
3293 }
3294 $message = 'Bestellung wurde als bezahlt markiert.';
3295 break;
3296
3297 case 'processing':
3298 $data['status'] = 'processing';
3299 $message = 'Bestellung wurde in Bearbeitung gesetzt.';
3300 break;
3301
3302 case 'ready':
3303 $data['shipping_status'] = 'ready';
3304 if (in_array((string)($data['status'] ?? ''), array('new', 'payment_pending', 'paid'), true)) {
3305 $data['status'] = 'processing';
3306 }
3307 $message = 'Bestellung wurde als versandbereit markiert.';
3308 break;
3309
3310 case 'shipped':
3311 $data['shipping_status'] = 'shipped';
3312 $data['status'] = 'shipped';
3313 if (trim((string)($data['shipped_date'] ?? '')) === '') {
3314 $data['shipped_date'] = date('Y-m-d H:i:s');
3315 }
3316 $message = 'Bestellung wurde als versendet markiert.';
3317 break;
3318
3319 case 'delivered':
3320 $data['shipping_status'] = 'delivered';
3321 $data['status'] = 'done';
3322 $message = 'Bestellung wurde als zugestellt und abgeschlossen markiert.';
3323 break;
3324
3325 case 'cancel':
3326 $data['status'] = 'cancelled';
3327 if (!in_array((string)($data['payment_status'] ?? ''), array('completed', 'paid', 'refunded'), true)) {
3328 $data['payment_status'] = 'cancelled';
3329 }
3330 $message = 'Bestellung wurde storniert.';
3331 break;
3332
3333 case 'refund':
3334 $data['payment_status'] = 'refunded';
3335 $data['status'] = 'cancelled';
3336 if ((string)($data['shipping_status'] ?? '') !== 'delivered') {
3337 $data['shipping_status'] = 'returned';
3338 }
3339 $message = 'Bestellung wurde als erstattet markiert.';
3340 break;
3341
3342 default:
3343 return array(false, 'Unbekannte Bestellaktion.');
3344 }
3345
3346 $ok = $this->updateOrderAdmin($id, $data);
3347 if ($ok) {
3348 $this->addOrderHistory($id, 'quick_action', '', $action, $message);
3349 return array(true, $message);
3350 }
3351 return array(false, 'Bestellaktion konnte nicht gespeichert werden.');
3352 }
3353
3354 public function deleteOrder(int $id): bool {
3355 $this->install();
3356 return $this->db()->update($this->dd('shopOrder'), array('trash' => 1, 'update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0) !== 0;
3357 }
3358
3359 public function orderByPaymentReference(string $provider, string $reference): ?array {
3360 $this->install();
3361 $row = $this->db()->select1($this->dd('shopOrder'), 'payment_provider = ' . $this->sqlValue($provider) . ' AND payment_reference = ' . $this->sqlValue($reference) . ' AND trash = 0', '*', 0);
3362 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) return null;
3363 $row['items'] = $this->orderItems((int)$row['id']);
3364 return $row;
3365 }
3366
3367 public function orderItems(int $orderId): array {
3368 $rows = $this->db()->select($this->dd('shopOrderItem'), 'order_id = ' . (int)$orderId . ' AND trash = 0', '*', 'id ASC', 'ASC', '', 0, 0, 0);
3369 return is_array($rows) ? $rows : array();
3370 }
3371
3372 private function paymentProcessingRetrySeconds(): int {
3373 $seconds = (int)dbx()->get_config('dbxShop', 'payment_processing_retry_seconds', 300);
3374 return max(60, min(3600, $seconds));
3375 }
3376
3381 private function isStalePaymentProcessing(array $order, int $retrySeconds, ?int $now = null): bool {
3382 if (strtolower(trim((string)($order['payment_status'] ?? ''))) !== 'processing') {
3383 return false;
3384 }
3385 $updatedAt = strtotime((string)($order['update_date'] ?? ''));
3386 if ($updatedAt === false || $updatedAt <= 0) {
3387 return false;
3388 }
3389 $now = $now ?? time();
3390 return $updatedAt <= $now - max(60, $retrySeconds);
3391 }
3392
3400 public function claimOrderPayment(int $orderId, string $provider, string $reference): bool {
3401 $this->install();
3402 $provider = trim($provider);
3403 $reference = trim($reference);
3404 if ($orderId <= 0 || $provider === '' || $reference === '') return false;
3405
3406 $db = $this->db();
3407 if ($db->begin($this->dd('shopOrder')) !== 1) return false;
3408 try {
3409 $order = $db->select1($this->dd('shopOrder'), 'id = ' . $orderId . ' AND trash = 0', '*', 0);
3410 $oldStatus = strtolower(trim((string)($order['payment_status'] ?? '')));
3411 $staleProcessing = is_array($order)
3412 && $this->isStalePaymentProcessing($order, $this->paymentProcessingRetrySeconds());
3413 if (!is_array($order)
3414 || !hash_equals($provider, (string)($order['payment_provider'] ?? ''))
3415 || !hash_equals($reference, (string)($order['payment_reference'] ?? ''))
3416 || (!in_array($oldStatus, array('open', 'created', 'failed'), true) && !$staleProcessing)) {
3417 $db->rollback($this->dd('shopOrder'));
3418 return false;
3419 }
3420
3421 $where = 'id = ' . $orderId . ' AND trash = 0'
3422 . ' AND payment_provider = ' . $this->sqlValue($provider)
3423 . ' AND payment_reference = ' . $this->sqlValue($reference)
3424 . ' AND payment_status = ' . $this->sqlValue($oldStatus);
3425 if ($staleProcessing) {
3426 // Das alte Lease-Datum macht auch den Recovery-Claim zu einem
3427 // Compare-and-swap. Ein paralleler Recovery-Request verliert.
3428 $where .= ' AND update_date = ' . $this->sqlValue((string)($order['update_date'] ?? ''));
3429 }
3430 if ($db->update($this->dd('shopOrder'), array(
3431 'payment_status' => 'processing',
3432 'status' => 'payment_pending',
3433 'update_date' => date('Y-m-d H:i:s'),
3434 ), $where, 0) !== 1 || (int)$db->_update_count !== 1) {
3435 $db->rollback($this->dd('shopOrder'));
3436 return false;
3437 }
3438 $claimMessage = $staleProcessing
3439 ? 'Verwaister Zahlungsabschluss wurde mit demselben Idempotency-Key erneut beansprucht.'
3440 : 'Zahlungsabschluss wurde atomar beansprucht.';
3441 if (!$this->addOrderHistory($orderId, 'payment', $oldStatus, 'processing', $claimMessage)
3442 || $db->commit($this->dd('shopOrder')) !== 1) {
3443 throw new \RuntimeException('payment_claim_commit_failed');
3444 }
3445 return true;
3446 } catch (\Throwable $e) {
3447 $db->rollback($this->dd('shopOrder'));
3448 dbx()->debug('#Shop payment claim rollback order=(' . $orderId . ') error=(' . $e->getMessage() . ')');
3449 return false;
3450 }
3451 }
3452
3457 public function updateOrderPayment(int $orderId, string $provider, string $status, string $reference = '', array $payload = array()): bool {
3458 $this->install();
3459 $provider = trim($provider);
3460 $status = strtolower(trim($status));
3461 $reference = trim($reference);
3462 $allowed = array('open', 'created', 'processing', 'pending', 'completed', 'paid', 'failed', 'cancelled', 'refunded');
3463 if ($orderId <= 0 || $provider === '' || !in_array($status, $allowed, true)) return false;
3464
3465 $db = $this->db();
3466 if ($db->begin($this->dd('shopOrder')) !== 1) return false;
3467 try {
3468 $order = $db->select1($this->dd('shopOrder'), 'id = ' . $orderId . ' AND trash = 0', '*', 0);
3469 if (!is_array($order)) {
3470 $db->rollback($this->dd('shopOrder'));
3471 return false;
3472 }
3473 $oldProvider = trim((string)($order['payment_provider'] ?? ''));
3474 $oldReference = trim((string)($order['payment_reference'] ?? ''));
3475 $oldStatus = strtolower((string)($order['payment_status'] ?? 'open'));
3476 if (($oldProvider !== '' && !hash_equals($oldProvider, $provider))
3477 || ($oldReference !== '' && $reference !== '' && !hash_equals($oldReference, $reference))) {
3478 $db->rollback($this->dd('shopOrder'));
3479 return false;
3480 }
3481 if ($reference === '') $reference = $oldReference;
3482
3483 if ((in_array($oldStatus, array('completed', 'paid'), true)
3484 && !in_array($status, array('completed', 'paid', 'refunded'), true))
3485 || ($oldStatus === 'refunded' && $status !== 'refunded')
3486 || ($oldStatus === 'cancelled' && !in_array($status, array('cancelled', 'refunded'), true))) {
3487 $db->rollback($this->dd('shopOrder'));
3488 return false;
3489 }
3490
3491 if ($oldStatus === $status && $oldProvider === $provider && $oldReference === $reference) {
3492 $db->rollback($this->dd('shopOrder'));
3493 return true;
3494 }
3495
3496 $orderStatus = in_array($status, array('completed', 'paid'), true)
3497 ? 'paid'
3498 : (in_array($status, array('cancelled', 'refunded'), true) ? 'cancelled' : 'payment_pending');
3499 $where = 'id = ' . $orderId . ' AND trash = 0 AND payment_status = ' . $this->sqlValue($oldStatus);
3500 if ($db->update($this->dd('shopOrder'), array(
3501 'update_date' => date('Y-m-d H:i:s'),
3502 'payment_provider' => $provider,
3503 'payment_status' => $status,
3504 'payment_reference' => $reference,
3505 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
3506 'status' => $orderStatus,
3507 ), $where, 0) !== 1 || (int)$db->_update_count !== 1) {
3508 $db->rollback($this->dd('shopOrder'));
3509 return false;
3510 }
3511 if (!$this->addOrderHistory($orderId, 'payment', $oldStatus, $status, 'Zahlungsstatus wurde aktualisiert.')
3512 || $db->commit($this->dd('shopOrder')) !== 1) {
3513 throw new \RuntimeException('payment_update_commit_failed');
3514 }
3515 } catch (\Throwable $e) {
3516 $db->rollback($this->dd('shopOrder'));
3517 dbx()->debug('#Shop payment rollback order=(' . $orderId . ') error=(' . $e->getMessage() . ')');
3518 return false;
3519 }
3520
3521 if (in_array($status, array('cancelled', 'refunded'), true)) {
3522 $fresh = $this->orderById($orderId);
3523 if (is_array($fresh)) {
3524 $this->releaseStockForOrder($fresh, 'Bestand wurde durch Zahlungsstatus zurueckgebucht.');
3525 }
3526 }
3527 return true;
3528 }
3529
3530 public function addOrderHistory(int $orderId, string $eventType, string $oldValue = '', string $newValue = '', string $message = ''): bool {
3531 if ($orderId <= 0) {
3532 return false;
3533 }
3534 $order = $this->db()->select1($this->dd('shopOrder'), 'id = ' . (int)$orderId . ' AND trash = 0', 'owner,uid', 0);
3535 $owner = is_array($order) ? (int)($order['owner'] ?? 0) : 0;
3536 if ($owner <= 0 && is_array($order)) {
3537 $owner = (int)($order['uid'] ?? 0);
3538 }
3539 $data = array(
3540 'order_id' => $orderId,
3541 'event_type' => $eventType,
3542 'old_value' => $oldValue,
3543 'new_value' => $newValue,
3544 'message' => $message,
3545 );
3546 if ($owner > 0) {
3547 $data['owner'] = $owner;
3548 }
3549 return $this->db()->insert($this->dd('shopOrderHistory'), $data, 0) === 1;
3550 }
3551
3552 public function orderHistory(int $orderId): array {
3553 $rows = $this->db()->select($this->dd('shopOrderHistory'), 'order_id = ' . (int)$orderId . ' AND trash = 0', '*', 'create_date DESC, id DESC', 'ASC', '', 0, 0, 0);
3554 return is_array($rows) ? $rows : array();
3555 }
3556
3557 public function hasOrderHistoryEvent(int $orderId, string $eventType, string $newValue = ''): bool {
3558 if ($orderId <= 0 || trim($eventType) === '') return false;
3559 $where = 'order_id = ' . $orderId
3560 . ' AND event_type = ' . $this->sqlValue(trim($eventType))
3561 . ' AND trash = 0';
3562 if ($newValue !== '') {
3563 $where .= ' AND new_value = ' . $this->sqlValue($newValue);
3564 }
3565 return $this->db()->count($this->dd('shopOrderHistory'), $where) > 0;
3566 }
3567
3568 public function nextInvoiceNo(): string {
3569 $prefix = 'R' . date('Y');
3570 $rows = $this->db()->select($this->dd('shopOrder'), 'invoice_no LIKE ' . $this->sqlValue($prefix . '%'), 'invoice_no', 'invoice_no DESC', 'DESC', '', 1, 0, 0);
3571 $last = is_array($rows) && isset($rows[0]['invoice_no']) ? (string)$rows[0]['invoice_no'] : '';
3572 $next = 1;
3573 if (preg_match('/(\d+)$/', $last, $m)) {
3574 $next = ((int)$m[1]) + 1;
3575 }
3576 return $prefix . '-' . str_pad((string)$next, 5, '0', STR_PAD_LEFT);
3577 }
3578
3579 private function invoiceArchiveDir(string $year): string {
3580 $base = rtrim(dbx()->get_base_dir(), '/\\');
3581 return $base . '/files/shop/invoices/' . $year;
3582 }
3583
3584 private function invoiceArchiveRelPath(string $year, string $fileName): string {
3585 return 'files/shop/invoices/' . $year . '/' . $fileName;
3586 }
3587
3588 private function pdfText(string $text): string {
3589 $text = str_replace(array("\r\n", "\r"), "\n", $text);
3590 $text = strtr($text, array(
3591 '€' => 'EUR',
3592 '„' => '"',
3593 '“' => '"',
3594 '’' => "'",
3595 '–' => '-',
3596 '—' => '-',
3597 ));
3598 $converted = function_exists('iconv') ? @iconv('UTF-8', 'Windows-1252//TRANSLIT', $text) : false;
3599 if ($converted !== false) {
3600 $text = $converted;
3601 }
3602 return str_replace(array('\\', '(', ')'), array('\\\\', '\\(', '\\)'), $text);
3603 }
3604
3605 private function createSimpleInvoicePdf(array $order, string $absFile): bool {
3606 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
3607 $invoiceDate = trim((string)($order['invoice_date'] ?? date('Y-m-d')));
3608 $lines = array(
3609 'Rechnung ' . $invoiceNo,
3610 'Datum: ' . $invoiceDate,
3611 'Bestellung: ' . (string)($order['order_no'] ?? ''),
3612 '',
3613 'Kunde:',
3614 (string)($order['customer_name'] ?? ''),
3615 (string)($order['customer_email'] ?? ''),
3616 );
3617 $address = trim((string)($order['shipping_address'] ?? ''));
3618 if ($address !== '') {
3619 $lines[] = '';
3620 $lines[] = 'Lieferadresse:';
3621 foreach (explode("\n", str_replace("\r", '', $address)) as $line) {
3622 $lines[] = $line;
3623 }
3624 }
3625 $lines[] = '';
3626 $lines[] = 'Positionen:';
3627 foreach ((array)($order['items'] ?? array()) as $item) {
3628 $title = trim((string)($item['title'] ?? 'Artikel'));
3629 $sku = trim((string)($item['sku'] ?? ''));
3630 $qty = (int)($item['qty'] ?? 0);
3631 $total = number_format((float)($item['total_gross'] ?? 0), 2, ',', '.') . ' EUR';
3632 $tax = number_format((float)($item['tax_rate'] ?? 0), 2, ',', '.') . ' % MwSt.';
3633 $lines[] = $qty . ' x ' . $title . ($sku !== '' ? ' [' . $sku . ']' : '') . ' - ' . $total . ' (' . $tax . ')';
3634 }
3635 $lines[] = '';
3636 $lines[] = 'Gesamtbetrag: ' . number_format((float)($order['total_gross'] ?? 0), 2, ',', '.') . ' EUR';
3637 $lines[] = '';
3638 $lines[] = 'Dieser Beleg wurde aus dem gespeicherten Bestell-Snapshot erzeugt.';
3639
3640 $content = "BT\n/F1 18 Tf\n50 790 Td\n(" . $this->pdfText(array_shift($lines) ?: 'Rechnung') . ") Tj\n/F1 10 Tf\n0 -24 Td\n";
3641 foreach ($lines as $line) {
3642 foreach (explode("\n", wordwrap((string)$line, 95, "\n", true)) as $wrapped) {
3643 $content .= '(' . $this->pdfText($wrapped) . ") Tj\n0 -14 Td\n";
3644 }
3645 }
3646 $content .= "ET\n";
3647
3648 $objects = array();
3649 $objects[] = "<< /Type /Catalog /Pages 2 0 R >>";
3650 $objects[] = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
3651 $objects[] = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>";
3652 $objects[] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
3653 $objects[] = "<< /Length " . strlen($content) . " >>\nstream\n" . $content . "endstream";
3654
3655 $pdf = "%PDF-1.4\n";
3656 $offsets = array(0);
3657 foreach ($objects as $i => $object) {
3658 $offsets[$i + 1] = strlen($pdf);
3659 $pdf .= ($i + 1) . " 0 obj\n" . $object . "\nendobj\n";
3660 }
3661 $xref = strlen($pdf);
3662 $pdf .= "xref\n0 " . (count($objects) + 1) . "\n";
3663 $pdf .= "0000000000 65535 f \n";
3664 for ($i = 1; $i <= count($objects); $i++) {
3665 $pdf .= str_pad((string)$offsets[$i], 10, '0', STR_PAD_LEFT) . " 00000 n \n";
3666 }
3667 $pdf .= "trailer\n<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\nstartxref\n" . $xref . "\n%%EOF";
3668
3669 $dir = dirname($absFile);
3670 if (!is_dir($dir) && !@mkdir($dir, 0775, true)) {
3671 return false;
3672 }
3673 return @file_put_contents($absFile, $pdf) !== false;
3674 }
3675
3676 public function ensureOrderInvoicePdf(int $id): ?array {
3677 $this->install();
3678 $order = $this->orderById($id);
3679 if (!is_array($order)) {
3680 return null;
3681 }
3682
3683 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
3684 $invoiceDate = trim((string)($order['invoice_date'] ?? ''));
3685 $updates = array();
3686 if ($invoiceNo === '') {
3687 $invoiceNo = $this->nextInvoiceNo();
3688 $updates['invoice_no'] = $invoiceNo;
3689 }
3690 if ($invoiceDate === '') {
3691 $invoiceDate = date('Y-m-d');
3692 $updates['invoice_date'] = $invoiceDate;
3693 }
3694 if ($updates !== array()) {
3695 $updates['update_date'] = date('Y-m-d H:i:s');
3696 $this->db()->update($this->dd('shopOrder'), $updates, 'id = ' . (int)$id . ' AND trash = 0', 0);
3697 $order = $this->orderById($id) ?: $order;
3698 }
3699
3700 $year = substr($invoiceDate, 0, 4);
3701 if (!preg_match('/^\d{4}$/', $year)) {
3702 $year = date('Y');
3703 }
3704 $safeNo = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $invoiceNo) ?: ('rechnung-' . $id);
3705 $fileName = $safeNo . '.pdf';
3706 $relPath = $this->invoiceArchiveRelPath($year, $fileName);
3707 $absPath = $this->invoiceArchiveDir($year) . '/' . $fileName;
3708 $oldRelPath = trim((string)($order['invoice_pdf_path'] ?? ''));
3709 $created = false;
3710
3711 if (!is_file($absPath)) {
3712 if (!$this->createSimpleInvoicePdf($order, $absPath)) {
3713 return null;
3714 }
3715 $created = true;
3716 }
3717
3718 $this->db()->update($this->dd('shopOrder'), array(
3719 'invoice_pdf_path' => $relPath,
3720 'invoice_pdf_date' => date('Y-m-d H:i:s'),
3721 'update_date' => date('Y-m-d H:i:s'),
3722 ), 'id = ' . (int)$id . ' AND trash = 0', 0);
3723 if ($created || $oldRelPath !== $relPath) {
3724 $this->addOrderHistory($id, 'invoice_pdf', '', $relPath, 'Rechnungs-PDF wurde erzeugt oder aktualisiert.');
3725 }
3726 return $this->orderById($id);
3727 }
3728
3729 public function invoicePdfAbsolutePath(array $order): string {
3730 $rel = trim((string)($order['invoice_pdf_path'] ?? ''));
3731 if ($rel === '') {
3732 return '';
3733 }
3734 $base = rtrim(dbx()->get_base_dir(), '/\\');
3735 $path = $base . '/' . ltrim($rel, '/\\');
3736 return is_file($path) ? $path : '';
3737 }
3738
3739 public function saveWithdrawal(array $data): ?array {
3740 $this->install();
3741 $orderNo = trim((string)($data['order_no'] ?? ''));
3742 $order = $orderNo !== '' ? $this->orderByNo($orderNo) : null;
3743 $orderId = is_array($order) ? (int)($order['id'] ?? 0) : 0;
3744 $ok = (int)$this->db()->insert($this->dd('shopWithdrawal'), array(
3745 'order_id' => $orderId,
3746 'order_no' => $orderNo,
3747 'customer_name' => trim((string)($data['customer_name'] ?? '')),
3748 'customer_email' => trim((string)($data['customer_email'] ?? '')),
3749 'customer_address' => trim((string)($data['customer_address'] ?? '')),
3750 'reason' => trim((string)($data['reason'] ?? '')),
3751 'status' => 'new',
3752 ), 0);
3753 if ($ok !== 1) {
3754 return null;
3755 }
3756 $id = (int)$this->db()->get_insert_id();
3757 if ($orderId > 0) {
3758 $this->addOrderHistory($orderId, 'withdrawal', '', 'new', 'Widerruf wurde eingereicht.');
3759 }
3760 return $this->withdrawalById($id);
3761 }
3762
3763 public function updateWithdrawalAdmin(int $id, string $status, string $note = ''): bool {
3764 $this->install();
3765 $allowed = array('new', 'processing', 'accepted', 'rejected', 'refunded', 'closed');
3766 if (!in_array($status, $allowed, true)) {
3767 return false;
3768 }
3769 $before = $this->withdrawalById($id);
3770 if (!is_array($before)) {
3771 return false;
3772 }
3773
3774 $ok = $this->db()->update($this->dd('shopWithdrawal'), array(
3775 'status' => $status,
3776 'admin_note' => $note !== '' ? $note : (string)($before['admin_note'] ?? ''),
3777 'update_date' => date('Y-m-d H:i:s'),
3778 ), 'id = ' . (int)$id . ' AND trash = 0', 0);
3779
3780 $orderId = (int)($before['order_id'] ?? 0);
3781 if ($orderId > 0) {
3782 $old = (string)($before['status'] ?? '');
3783 if ($old !== $status) {
3784 $this->addOrderHistory($orderId, 'withdrawal_status', $old, $status, 'Widerrufsstatus wurde geaendert.');
3785 }
3786 if (in_array($status, array('accepted', 'refunded'), true)) {
3787 $order = $this->orderById($orderId);
3788 if (is_array($order)) {
3789 $this->releaseStockForOrder($order, 'Bestand wurde durch Widerruf zurueckgebucht.');
3790 }
3791 }
3792 }
3793
3794 return $ok !== 0 || $this->withdrawalById($id) !== null;
3795 }
3796
3797 public function withdrawalById(int $id): ?array {
3798 $row = $this->db()->select1($this->dd('shopWithdrawal'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
3799 return is_array($row) ? $row : null;
3800 }
3801
3802 public function withdrawals(array $filters = array(), int $limit = 50, int $offset = 0): array {
3803 $this->install();
3804 $where = array('trash = 0');
3805 $query = trim((string)($filters['query'] ?? ''));
3806 if ($query !== '') {
3807 $like = $this->sqlLikeValue($query);
3808 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ')';
3809 }
3810 $status = trim((string)($filters['status'] ?? ''));
3811 if ($status !== '') {
3812 $where[] = 'status = ' . $this->sqlValue($status);
3813 }
3814 $rows = $this->db()->select($this->dd('shopWithdrawal'), implode(' AND ', $where), '*', 'create_date DESC, id DESC', 'ASC', '', $limit > 0 ? $limit : 0, max(0, $offset), 0);
3815 return is_array($rows) ? $rows : array();
3816 }
3817
3818 public function withdrawalCount(array $filters = array()): int {
3819 $where = array('trash = 0');
3820 $query = trim((string)($filters['query'] ?? ''));
3821 if ($query !== '') {
3822 $like = $this->sqlLikeValue($query);
3823 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ')';
3824 }
3825 $status = trim((string)($filters['status'] ?? ''));
3826 if ($status !== '') {
3827 $where[] = 'status = ' . $this->sqlValue($status);
3828 }
3829 return max(0, (int)$this->db()->count($this->dd('shopWithdrawal'), implode(' AND ', $where)));
3830 }
3831
3832 public function withdrawalsForOrder(int $orderId): array {
3833 if ($orderId <= 0) {
3834 return array();
3835 }
3836 $rows = $this->db()->select($this->dd('shopWithdrawal'), 'order_id = ' . (int)$orderId . ' AND trash = 0', '*', 'create_date DESC, id DESC', 'ASC', '', 0, 0, 0);
3837 return is_array($rows) ? $rows : array();
3838 }
3839
3840 public function dashboardStats(): array {
3841 $this->install();
3842 $ordersOpen = (int)$this->db()->count($this->dd('shopOrder'), "trash = 0 AND status IN ('new','payment_pending','paid','processing')");
3843 $paymentsOpen = (int)$this->db()->count($this->dd('shopOrder'), "trash = 0 AND payment_status IN ('open','created','pending')");
3844 $shipOpen = (int)$this->db()->count($this->dd('shopOrder'), "trash = 0 AND shipping_status IN ('open','ready')");
3845 $withdrawalsOpen = (int)$this->db()->count($this->dd('shopWithdrawal'), "trash = 0 AND status IN ('new','processing')");
3846 $stockLow = 0;
3847 if ($this->stockEnabled()) {
3848 $stockLow = (int)$this->db()->count($this->dd('shopProduct'), "trash = 0 AND active = 1 AND product_type = 'physical' AND stock <= 3");
3849 }
3850 return array(
3851 'orders_open' => $ordersOpen,
3852 'payments_open' => $paymentsOpen,
3853 'shipping_open' => $shipOpen,
3854 'withdrawals_open' => $withdrawalsOpen,
3855 'stock_low' => $stockLow,
3856 'products_active' => (int)$this->db()->count($this->dd('shopProduct'), 'trash = 0 AND active = 1'),
3857 );
3858 }
3859}
3860?>
$table['server']
Definition .dd.php:6
if(!is_array( $before)||(int)( $before[ 'id'] ?? 0) !==$mediaId) if((string)($before['media_type'] ?? '') !=='video') $updated
$cfg
needsDemoSeed()
Schneller Seed-Check ohne Produktdekoration und N+1-Abfragen.
importChannelOrder(string $channelKey, array $payload)
orderByPaymentReference(string $provider, string $reference)
productsByIds(array $ids)
Dekoriert nur die vom Report tatsaechlich sichtbaren Artikel.
setProductGroupForProducts(array $ids, int $groupId)
exportProductToChannel(int $productId, string $channelKey)
saveImage(int $productId, int $groupId, string $imagePath, string $title, string $alt, int $isPrimary=0, int $sorter=100)
imagesForProduct(int $productId, array $groups=array())
moveProductGroupParent(int $groupId, int $parentId)
saveProductAttributeValue(int $productId, int $attributeId, string $value)
saveProductChannelMapping(int $productId, string $channelKey, array $data)
hasOrderHistoryEvent(int $orderId, string $eventType, string $newValue='')
updateImageMediaReference(int $imageId, int $mediaId, string $imagePath='')
withdrawals(array $filters=array(), int $limit=50, int $offset=0)
groupsByParent(int $parentId=0, bool $activeOnly=true)
removeChannelFromProducts(array $ids, string $channelKey)
addChannelToProducts(array $ids, string $channelKey)
updateWithdrawalAdmin(int $id, string $status, string $note='')
claimOrderPayment(int $orderId, string $provider, string $reference)
Beansprucht einen Provider-Abschluss atomar.
productChannelMapping(int $productId, string $channelKey)
saveMediaImage(int $productId, int $groupId, int $mediaId, string $title='', string $alt='', int $isPrimary=0, int $sorter=100)
saveProductAttributeValues(int $productId, array $values)
updateChannelGroup(int $id, array $data, array $channelKeys)
attributeDefinitionsForProduct(int $productId, bool $activeOnly=true)
saveProductChannelOverrides(int $productId, array $activeChannelKeys)
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='')
orders(array $filters=array(), int $limit=50, int $offset=0, string $sort='create_date', string $direction='DESC')
updateOrderQuickAction(int $id, string $action)
productBySku(string $sku, bool $activeOnly=true)
updateOrderPayment(int $orderId, string $provider, string $status, string $reference='', array $payload=array())
Aktualisiert den Zahlungsstatus idempotent und verhindert Downgrades terminaler Zahlungen sowie Refer...
catalogCandidates(string $channelKey='shop')
Liefert leichte, gebuendelt angereicherte Katalogzeilen.
exportProductsToChannel(array $ids, string $channelKey)
removeProductImageAssociation(int $imageId, int $productId=0)
addOrderHistory(int $orderId, string $eventType, string $oldValue='', string $newValue='', string $message='')
attributeDefinitionsForGroup(int $groupId, bool $activeOnly=true)
install(bool $maintenance=false)
Fuehrt die explizite Schema- und Defaultpflege aus.
get_base_dir(int $cutData=0)
Liefert das Basisverzeichnis der Installation.
Definition dbxApi.php:1507
foreach(array('bootstrapRowColumns', 'setBootstrapColumnLayout', 'addBootstrapColumn', 'dissolveBootstrapColumns',) as $function) $keys
if(!is_array($rows)) $byId
if(!is_object( $db)) if($db->connect_db_server($server) !==1) $created
if(preg_match('/core\.js\? foreach[^"\']*v=(\d+)/', $designTemplate, $assetMatch) !== 1 || !str_contains($shopReference, 'dbxapp-Asset-Version ' . $assetMatch[1])) foreach (array( 'reference\\archive', 'provision_docs_content.php', 'dbxSelfTest') as $needle)(array('Installation'=> $installation, 'Installations-Tutorial'=> $installationTutorial, 'SelfTest'=> $selfTest) as $label=> $html)
foreach(array( 'forms', 'reports', 'dd', 'db', 'audit', 'objects', 'escaping', 'get', 'action_links') as $requiredKey) foreach(array('{fid}_{event}-Callback-Defaults', 'add_rep()', '{rpt:colspan}') as $requiredReportRule) $reference
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
if( $demoId<=0) foreach(array('create_date', 'create_uid', 'update_date', 'update_uid', 'owner',) as $systemField) $items
DBX schema administration.