dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
Loading...
Searching...
No Matches
dbxShopRepository.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxShop;
3
5 private function db() {
6 return dbx()->get_system_obj('dbxDB');
7 }
8
9 private function dd(string $name): string {
10 return 'dbxShop|' . $name;
11 }
12
13 private function sqlValue($value): string {
14 if ($value === null) {
15 return "''";
16 }
17 return "'" . str_replace("'", "''", (string)$value) . "'";
18 }
19
20 private function sqlLikeValue(string $value): string {
21 return $this->sqlValue('%' . $value . '%');
22 }
23
24 private function syncDdToDb(string $dd): bool {
25 $oDD = dbx()->get_system_obj('dbxDD');
26 $oDD->sync_dd_to_db('dbxShop', $dd, 'reset');
27 for ($i = 0; $i < 40; $i++) {
28 $state = $oDD->sync_dd_to_db('dbxShop', $dd, 'apply');
29 $status = (string)($state['status'] ?? '');
30 if ($status === 'finished') {
31 return true;
32 }
33 if (in_array($status, array('error', 'cancelled'), true)) {
34 return false;
35 }
36 }
37 return false;
38 }
39
40 private function syncShopSchemaFromDd(): void {
41 static $done = false;
42 if ($done) {
43 return;
44 }
45
46 $version = 'shop-dd-20260713-2';
47 if ((string)dbx()->get_config('dbxShop', 'schema_sync_version', '') === $version) {
48 $done = true;
49 return;
50 }
51
52 foreach (array(
53 'shopProductGroup',
54 'shopChannel',
55 'shopProduct',
56 'shopProductImage',
57 'shopAttributeDefinition',
58 'shopProductAttributeValue',
59 'shopProductGroupMap',
60 'shopProductChannel',
61 'shopShippingGroup',
62 'shopProductShippingGroupMap',
63 'shopChannelGroup',
64 'shopChannelGroupChannel',
65 'shopProductChannelGroupMap',
66 'shopOrder',
67 'shopOrderItem',
68 'shopOrderHistory',
69 'shopWithdrawal',
70 ) as $dd) {
71 if (!$this->syncDdToDb($dd)) {
72 throw new \RuntimeException('dbxShop-DD konnte nicht mit der Datenbank synchronisiert werden: ' . $dd);
73 }
74 }
75
76 $cfg = dbx()->get_config('dbxShop', '', array());
77 $cfg = is_array($cfg) ? $cfg : array();
78 $cfg['schema_sync_version'] = $version;
79 dbx()->set_config('dbxShop', $cfg);
80 $done = true;
81 }
82
83 public function install(): void {
84 static $installed = false;
85 if ($installed) {
86 return;
87 }
88 $this->syncShopSchemaFromDd();
89 $this->syncChannelDefaults();
90 $this->syncPrimaryProductGroups();
91 $this->syncSingleGroupImages();
92 $installed = true;
93 return;
94 }
95
96 private function syncPrimaryProductGroups(): void {
97 $rows = $this->db()->select($this->dd('shopProduct'), 'trash = 0 AND (product_group_id IS NULL OR product_group_id <= 0)', 'id', '', 'ASC', '', 0, 0, 0);
98 foreach ((is_array($rows) ? $rows : array()) as $row) {
99 $productId = (int)($row['id'] ?? 0);
100 if ($productId <= 0) continue;
101 $maps = $this->db()->select($this->dd('shopProductGroupMap'), 'product_id = ' . $productId, '*', 'is_primary DESC', 'ASC', '', 0, 1, 0);
102 $map = is_array($maps) && isset($maps[0]) ? $maps[0] : array();
103 $groupId = (int)($map['group_id'] ?? 0);
104 if ($groupId > 0) {
105 $this->db()->update($this->dd('shopProduct'), array('product_group_id' => $groupId), 'id = ' . $productId, 0);
106 }
107 }
108 }
109
110 private function syncSingleGroupImages(): void {
111 $groups = $this->db()->select($this->dd('shopProductGroup'), 'trash = 0', 'id', '', 'ASC', '', 0, 0, 0);
112 foreach ((is_array($groups) ? $groups : array()) as $group) {
113 $groupId = (int)($group['id'] ?? 0);
114 if ($groupId <= 0) continue;
115 $rows = $this->db()->select(
116 $this->dd('shopProductImage'),
117 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . $groupId,
118 '*',
119 'is_primary DESC, sorter ASC, title ASC',
120 'ASC',
121 '',
122 0,
123 0,
124 0
125 );
126 $keep = 0;
127 foreach ((is_array($rows) ? $rows : array()) as $row) {
128 $id = (int)($row['id'] ?? 0);
129 if ($id <= 0) continue;
130 if ($keep <= 0) {
131 $keep = $id;
132 $this->db()->update($this->dd('shopProductImage'), array('is_primary' => 1, 'sorter' => 10), 'id = ' . $id, 0);
133 continue;
134 }
135 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), 'id = ' . $id, 0);
136 }
137 }
138 }
139
140 private function syncChannelDefaults(): void {
141 $channels = array(
142 array('shop', 'Shop', 'Eigener Shop ohne externe API.', 'shop', 'internal', 1, 0, '', '', '', '', 10),
143 array('amazon', 'Amazon', 'Amazon Marketplace Integration fuer Artikel-Export und spaeteren Order-Import.', 'amazon', 'api', 1, 1, 'https://sellingpartnerapi-eu.amazon.com', 'A1PA6795UKMFR9', 'ORDER_CHANGE', "Listings Items\nOrders\nNotifications", 20),
144 array('ebay', 'eBay', 'eBay Marketplace Integration fuer Listings und Bestellrueckmeldungen.', 'ebay', 'api', 1, 1, 'https://api.ebay.com', 'EBAY_DE', '', "https://api.ebay.com/oauth/api_scope/sell.inventory\nhttps://api.ebay.com/oauth/api_scope/sell.fulfillment\nhttps://api.ebay.com/oauth/api_scope/commerce.notification.subscription", 30),
145 array('kleinanzeigen', 'Kleinanzeigen', 'Kleinanzeigen ist als Channel vorbereitet. Eine allgemein frei nutzbare offizielle Anzeigen-/Order-API ist nicht hinterlegt; nutzen Sie hier nur vertraglich freigegebene Schnittstellen oder manuelle Pflege.', 'kleinanzeigen', 'manual', 1, 0, '', '', '', '', 40),
146 array('mobile', 'mobile.de', 'mobile.de Channel fuer Fahrzeug- oder Angebotsdaten ueber Seller API und Lead API.', 'mobile', 'api', 1, 1, 'https://services.mobile.de/seller-api', '', 'lead-api', "seller-api\nbasic-auth\nlead-api", 50),
147 );
148 foreach ($channels as $c) {
149 $existing = $this->db()->select1($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($c[0]), '*', 0);
150 $existing = is_array($existing) ? $existing : array();
151 $values = array(
152 'channel_key' => $c[0],
153 'title' => trim((string)($existing['title'] ?? '')) !== '' ? (string)$existing['title'] : $c[1],
154 'description' => trim((string)($existing['description'] ?? '')) !== '' ? (string)$existing['description'] : $c[2],
155 'platform_type' => trim((string)($existing['platform_type'] ?? '')) !== '' && (string)($existing['platform_type'] ?? '') !== 'custom' ? (string)$existing['platform_type'] : $c[3],
156 'connection_mode' => trim((string)($existing['connection_mode'] ?? '')) !== '' && (string)($existing['connection_mode'] ?? '') !== 'manual' ? (string)$existing['connection_mode'] : $c[4],
157 'export_enabled' => array_key_exists('export_enabled', $existing) ? (int)$existing['export_enabled'] : $c[5],
158 'order_import_enabled' => array_key_exists('order_import_enabled', $existing) ? (int)$existing['order_import_enabled'] : $c[6],
159 'api_base_url' => trim((string)($existing['api_base_url'] ?? '')) !== '' ? (string)$existing['api_base_url'] : $c[7],
160 'marketplace_id' => trim((string)($existing['marketplace_id'] ?? '')) !== '' ? (string)$existing['marketplace_id'] : $c[8],
161 'notification_topic' => trim((string)($existing['notification_topic'] ?? '')) !== '' ? (string)$existing['notification_topic'] : $c[9],
162 'api_scope' => trim((string)($existing['api_scope'] ?? '')) !== '' ? (string)$existing['api_scope'] : $c[10],
163 'active' => array_key_exists('active', $existing) ? (int)$existing['active'] : 1,
164 'sorter' => array_key_exists('sorter', $existing) ? (int)$existing['sorter'] : $c[11],
165 );
166 $this->db()->save($this->dd('shopChannel'), $values, 'channel_key = ' . $this->sqlValue($c[0]), 0);
167 }
168 $oldEbayScopes = 'https://api.ebay.com/oauth/api_scope/sell.inventory https://api.ebay.com/oauth/api_scope/sell.fulfillment https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
169 $newEbayScopes = "https://api.ebay.com/oauth/api_scope/sell.inventory\nhttps://api.ebay.com/oauth/api_scope/sell.fulfillment\nhttps://api.ebay.com/oauth/api_scope/commerce.notification.subscription";
170 $this->db()->update(
171 $this->dd('shopChannel'),
172 array('api_scope' => $newEbayScopes),
173 'channel_key = ' . $this->sqlValue('ebay') . ' AND api_scope = ' . $this->sqlValue($oldEbayScopes),
174 0
175 );
176 }
177
178 private function seedDemoProductsWithDbxDb(): void {
179 if ($this->db()->count($this->dd('shopProduct'), 'trash = 0') > 0) {
180 return;
181 }
182
183 $this->updateProductGroup(0, array(
184 'group_key' => 'software',
185 'title' => 'Software',
186 'description' => 'Digitale dbXapp Pakete und Erweiterungen.',
187 'tax_class' => 'mwst1',
188 'display_variant' => 'gallery_grid',
189 'card_template' => 'product-card-default',
190 'detail_template' => 'product-detail-default',
191 'gallery_template' => 'image-gallery',
192 'gallery_visible_count' => 3,
193 'gallery_image_size' => 'original',
194 'gallery_lightbox_width' => '100vw',
195 'gallery_overflow' => 'grid',
196 'gallery_click' => 'lightbox',
197 'attribute_notes' => 'Lizenz, Paketumfang, CMS-Funktionen und KI-Funktionen.',
198 'active' => 1,
199 'sorter' => 10,
200 ));
201 $this->updateProductGroup(0, array(
202 'group_key' => 'service',
203 'title' => 'Dienstleistungen',
204 'description' => 'Installation, Wartung und Schulung.',
205 'tax_class' => 'mwst1',
206 'display_variant' => 'gallery_slider',
207 'card_template' => 'product-card-default',
208 'detail_template' => 'product-detail-default',
209 'gallery_template' => 'image-gallery',
210 'gallery_visible_count' => 1,
211 'gallery_image_size' => 'original',
212 'gallery_lightbox_width' => '100vw',
213 'gallery_overflow' => 'slider',
214 'gallery_click' => 'lightbox',
215 'attribute_notes' => 'Leistungsumfang, Dauer, Termin und Uebergabe.',
216 'active' => 1,
217 'sorter' => 20,
218 ));
219
220 $this->updateShippingGroup(0, array(
221 'group_key' => 'digital-free',
222 'title' => 'Digital / kein Versand',
223 'description' => 'Digitale Lieferung ohne Versandkosten.',
224 'shipping_way' => 'Download / Freischaltung',
225 'delivery_time' => 'Sofort nach Freischaltung',
226 'shipping_gross' => 0,
227 'free_from_gross' => -1,
228 'active' => 1,
229 'sorter' => 10,
230 ));
231 $this->updateShippingGroup(0, array(
232 'group_key' => 'service-remote',
233 'title' => 'Service / Termin',
234 'description' => 'Terminleistung ohne Paketversand.',
235 'shipping_way' => 'Termin / Remote / vor Ort nach Absprache',
236 'delivery_time' => 'Nach Terminvereinbarung',
237 'shipping_gross' => 0,
238 'free_from_gross' => -1,
239 'active' => 1,
240 'sorter' => 20,
241 ));
242
243 $this->updateChannelGroup(0, array(
244 'group_key' => 'software-shop',
245 'title' => 'Software Shop-Artikel',
246 'description' => 'Softwarepakete fuer Shop und Amazon.',
247 'active' => 1,
248 'sorter' => 20,
249 ), array('shop', 'amazon'));
250 $this->updateChannelGroup(0, array(
251 'group_key' => 'service-local',
252 'title' => 'Service lokal',
253 'description' => 'Dienstleistungen fuer Shop und Kleinanzeigen.',
254 'active' => 1,
255 'sorter' => 30,
256 ), array('shop', 'kleinanzeigen'));
257
258 $products = array(
259 array('DBX-DEMO-START', 'dbxapp-demo-paket', 'dbXapp Demo Paket', 'Software', 'digital', 'software', 'digital-free', 'software-shop', 'Kleine Website mit CMS, Medienverwaltung und direkter Frontend-Bearbeitung.', 'Testartikel fuer den Einstieg: Content-Seiten, Hero, Gallery und Medienbrowser sind vorbereitet.', 99.00, 0, 10, 'Demo', 'bi-window-sidebar', array('shop', 'amazon')),
260 array('DBX-INSTALLATION', 'dbxapp-installation', 'Installation', 'Dienstleistung', 'service', 'service', 'service-remote', 'service-local', 'Einrichtung von dbXapp auf Hosting oder eigenem Server.', 'Installation, Grundkonfiguration, erste Systempruefung und Uebergabe der lauffaehigen Umgebung.', 249.00, 0, 40, 'Service', 'bi-tools', array('shop', 'kleinanzeigen')),
261 );
262 foreach ($products as $p) {
263 $this->db()->save(
264 $this->dd('shopProduct'),
265 array(
266 'sku' => $p[0],
267 'slug' => $p[1],
268 'title' => $p[2],
269 'category' => $p[3],
270 'product_group_id' => $this->groupIdByKey($p[5]),
271 'product_type' => $p[4],
272 'summary' => $p[8],
273 'description' => $p[9],
274 'price_gross' => $p[10],
275 'currency' => 'EUR',
276 'tax_mode' => 'group',
277 'tax_rate' => -1,
278 'shipping_mode' => 'group',
279 'shipping_gross' => -1,
280 'stock' => $p[11],
281 'active' => 1,
282 'sorter' => $p[12],
283 'badge' => $p[13],
284 'image_icon' => $p[14],
285 'logo_variant' => '',
286 ),
287 'sku = ' . $this->sqlValue($p[0]),
288 0
289 );
290 $product = $this->productBySku($p[0], false);
291 $productId = (int)($product['id'] ?? 0);
292 if ($productId <= 0) continue;
293 $groupId = $this->groupIdByKey($p[5]);
294 if ($groupId > 0) {
295 $this->db()->save($this->dd('shopProductGroupMap'), array('product_id' => $productId, 'group_id' => $groupId, 'is_primary' => 1), 'product_id = ' . $productId . ' AND group_id = ' . $groupId, 0);
296 }
297 $shippingGroupId = $this->shippingGroupIdByKey($p[6]);
298 if ($shippingGroupId > 0) {
299 $this->db()->save($this->dd('shopProductShippingGroupMap'), array('product_id' => $productId, 'shipping_group_id' => $shippingGroupId, 'is_primary' => 1), 'product_id = ' . $productId . ' AND shipping_group_id = ' . $shippingGroupId, 0);
300 }
301 $channelGroupId = $this->channelGroupIdByKey($p[7]);
302 if ($channelGroupId > 0) {
303 $this->db()->save($this->dd('shopProductChannelGroupMap'), array('product_id' => $productId, 'channel_group_id' => $channelGroupId, 'is_primary' => 1), 'product_id = ' . $productId . ' AND channel_group_id = ' . $channelGroupId, 0);
304 }
305 foreach ($p[15] as $channelKey) {
306 $this->db()->save($this->dd('shopProductChannel'), array('product_id' => $productId, 'channel_key' => $channelKey, 'active' => 1, 'channel_sku' => $p[0], 'price_gross' => -1, 'shipping_gross' => -1), 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey), 0);
307 }
308 }
309
310 $softwareGroupId = $this->groupIdByKey('software');
311 if ($softwareGroupId > 0) {
312 $this->saveImage(0, $softwareGroupId, 'files/shop/img/software-dashboard.svg', 'Software Dashboard', 'dbXapp Software Dashboard', 1, 10);
313 }
314 $serviceGroupId = $this->groupIdByKey('service');
315 if ($serviceGroupId > 0) {
316 $this->saveImage(0, $serviceGroupId, 'files/shop/img/service-support.svg', 'Service und Schulung', 'dbXapp Installation, Wartung und Schulung', 1, 10);
317 }
318 }
319
320 public function seedDemoProducts(): void {
321 $this->install();
322 $this->seedDemoProductsWithDbxDb();
323 return;
324 }
325
326 private function groupIdByKey(string $key): int {
327 $row = $this->db()->select1($this->dd('shopProductGroup'), 'group_key = ' . $this->sqlValue($key), 'id', 0);
328 return (int)($row['id'] ?? 0);
329 }
330
331 private function shippingGroupIdByKey(string $key): int {
332 $row = $this->db()->select1($this->dd('shopShippingGroup'), 'group_key = ' . $this->sqlValue($key), 'id', 0);
333 return (int)($row['id'] ?? 0);
334 }
335
336 private function channelGroupIdByKey(string $key): int {
337 $row = $this->db()->select1($this->dd('shopChannelGroup'), 'group_key = ' . $this->sqlValue($key), 'id', 0);
338 return (int)($row['id'] ?? 0);
339 }
340
341 private function valueNum(string $value): ?float {
342 $clean = str_replace(',', '.', trim($value));
343 return is_numeric($clean) ? (float)$clean : null;
344 }
345
346 public function attributeDefinitionsForGroup(int $groupId, bool $activeOnly = true): array {
347 $this->install();
348 $where = 'trash = 0 AND group_id = ' . (int)$groupId;
349 if ($activeOnly) {
350 $where .= ' AND active = 1';
351 }
352 $rows = $this->db()->select($this->dd('shopAttributeDefinition'), $where, '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
353 return is_array($rows) ? $rows : array();
354 }
355
356 public function attributeDefinitionsForProduct(int $productId, bool $activeOnly = true): array {
357 $defs = array();
358 $seen = array();
359 foreach ($this->groupsForProduct($productId) as $group) {
360 foreach ($this->attributeDefinitionsForGroup((int)($group['id'] ?? 0), $activeOnly) as $definition) {
361 $id = (int)($definition['id'] ?? 0);
362 if ($id <= 0 || isset($seen[$id])) continue;
363 $seen[$id] = true;
364 $defs[] = $definition;
365 }
366 }
367 return $defs;
368 }
369
370 public function attributesForProduct(int $productId): array {
371 $this->install();
372 $values = $this->db()->select($this->dd('shopProductAttributeValue'), 'product_id = ' . (int)$productId . ' AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
373 $valueByAttribute = array();
374 foreach ((is_array($values) ? $values : array()) as $value) {
375 $valueByAttribute[(int)($value['attribute_id'] ?? 0)] = $value;
376 }
377
378 $rows = array();
379 foreach ($this->attributeDefinitionsForProduct($productId, true) as $definition) {
380 $attributeId = (int)($definition['id'] ?? 0);
381 if ($attributeId <= 0) {
382 continue;
383 }
384 $value = $valueByAttribute[$attributeId] ?? array();
385 $definition['value_text'] = $value['value_text'] ?? '';
386 $definition['value_num'] = $value['value_num'] ?? '';
387 $definition['unit_override'] = $value['unit_override'] ?? '';
388 $definition['value_active'] = $value['active'] ?? 0;
389 $rows[] = $definition;
390 }
391 foreach ($rows as &$row) {
392 $value = trim((string)($row['value_text'] ?? ''));
393 $unit = trim((string)($row['unit_override'] ?? '')) ?: trim((string)($row['unit'] ?? ''));
394 $row['display_value'] = $value !== '' && $unit !== '' ? $value . ' ' . $unit : $value;
395 }
396 unset($row);
397 return $rows;
398 }
399
400 public function allAttributeDefinitions(): array {
401 $this->install();
402 $groups = $this->groups();
403 $groupById = array();
404 foreach ($groups as $group) {
405 $groupById[(int)($group['id'] ?? 0)] = $group;
406 }
407 $defs = $this->db()->select($this->dd('shopAttributeDefinition'), 'trash = 0', '*', '', 'ASC', '', 0, 0, 0);
408 $defs = is_array($defs) ? $defs : array();
409 foreach ($defs as &$def) {
410 $group = $groupById[(int)($def['group_id'] ?? 0)] ?? array();
411 $def['group_title'] = (string)($group['title'] ?? '');
412 $def['group_key'] = (string)($group['group_key'] ?? '');
413 $def['_group_sorter'] = (int)($group['sorter'] ?? 9999);
414 }
415 unset($def);
416 usort($defs, fn($a, $b) => ((int)($a['_group_sorter'] ?? 9999) <=> (int)($b['_group_sorter'] ?? 9999))
417 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
418 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
419 foreach ($defs as &$def) {
420 unset($def['_group_sorter']);
421 }
422 unset($def);
423 return $defs;
424 }
425
426 public function attributeFilterDefinitions(): array {
427 $this->install();
428 $defs = array_values(array_filter(
430 fn($def) => (int)($def['active'] ?? 0) === 1 && (int)($def['filterable'] ?? 0) === 1
431 ));
432 $products = $this->db()->select($this->dd('shopProduct'), 'trash = 0 AND active = 1', 'id', '', 'ASC', '', 0, 0, 0);
433 $activeProducts = array();
434 foreach ((is_array($products) ? $products : array()) as $product) {
435 $activeProducts[(int)($product['id'] ?? 0)] = true;
436 }
437 foreach ($defs as &$def) {
438 $values = array();
439 $rows = $this->db()->select(
440 $this->dd('shopProductAttributeValue'),
441 'attribute_id = ' . (int)($def['id'] ?? 0) . ' AND trash = 0 AND active = 1',
442 '*',
443 'value_text ASC',
444 'ASC',
445 '',
446 0,
447 0,
448 0
449 );
450 foreach ((is_array($rows) ? $rows : array()) as $row) {
451 $productId = (int)($row['product_id'] ?? 0);
452 $value = trim((string)($row['value_text'] ?? ''));
453 if ($value !== '' && isset($activeProducts[$productId])) {
454 $values[$value] = $value;
455 }
456 }
457 $values = array_values($values);
458 natcasesort($values);
459 $values = array_values($values);
460 if ($values === array() && trim((string)($def['options'] ?? '')) !== '') {
461 $values = preg_split('~[|;\r\n]+~', (string)$def['options']) ?: array();
462 $values = array_values(array_filter(array_map('trim', $values), fn($v) => $v !== ''));
463 }
464 $def['values'] = $values;
465 }
466 unset($def);
467 return $defs;
468 }
469
470 public function saveAttributeDefinition(array $data): void {
471 $this->install();
472 $id = (int)($data['id'] ?? 0);
473 $groupId = (int)($data['group_id'] ?? 0);
474 $key = strtolower(trim(preg_replace('~[^a-z0-9_\\-]+~i', '_', (string)($data['attr_key'] ?? ''))));
475 $title = trim((string)($data['title'] ?? ''));
476 if ($groupId <= 0 || $key === '' || $title === '') {
477 return;
478 }
479 $type = (string)($data['input_type'] ?? 'text');
480 if (!in_array($type, array('text', 'select', 'number'), true)) {
481 $type = 'text';
482 }
483 $values = array(
484 'group_id' => $groupId,
485 'attr_key' => $key,
486 'title' => $title,
487 'input_type' => $type,
488 'unit' => trim((string)($data['unit'] ?? '')),
489 'options' => trim((string)($data['options'] ?? '')),
490 'required' => !empty($data['required']) ? 1 : 0,
491 'filterable' => !empty($data['filterable']) ? 1 : 0,
492 'comparable' => !empty($data['comparable']) ? 1 : 0,
493 'active' => !empty($data['active']) ? 1 : 0,
494 'sorter' => (int)($data['sorter'] ?? 100),
495 );
496 if ($id > 0) {
497 $this->db()->update($this->dd('shopAttributeDefinition'), $values, 'id = ' . (int)$id, 0);
498 return;
499 }
500 $this->db()->save(
501 $this->dd('shopAttributeDefinition'),
502 $values,
503 'group_id = ' . (int)$groupId . ' AND attr_key = ' . $this->sqlValue($key),
504 0
505 );
506 }
507
508 public function saveProductAttributeValue(int $productId, int $attributeId, string $value): void {
509 $this->install();
510 if ($productId <= 0 || $attributeId <= 0) {
511 return;
512 }
513 $value = trim($value);
514 $num = $this->valueNum($value);
515 $this->db()->save(
516 $this->dd('shopProductAttributeValue'),
517 array(
518 'product_id' => $productId,
519 'attribute_id' => $attributeId,
520 'value_text' => $value,
521 'value_num' => $num,
522 'active' => $value !== '' ? 1 : 0,
523 ),
524 'product_id = ' . (int)$productId . ' AND attribute_id = ' . (int)$attributeId,
525 0
526 );
527 }
528
529 public function saveProductAttributeValues(int $productId, array $values): void {
530 foreach ($values as $attributeId => $value) {
531 $this->saveProductAttributeValue($productId, (int)$attributeId, (string)$value);
532 }
533 }
534
535 private function decorateProduct(array $row): array {
536 $row['groups'] = $this->groupsForProduct((int)$row['id']);
537 $row['shipping_groups'] = $this->shippingGroupsForProduct((int)$row['id']);
538 $row['channel_groups'] = $this->channelGroupsForProduct((int)$row['id']);
539 $row['channels'] = $this->channelsForProduct((int)$row['id']);
540 $row['images'] = $this->imagesForProduct((int)$row['id'], $row['groups']);
541 $row['attributes'] = $this->attributesForProduct((int)$row['id']);
542 $primary = $row['groups'][0] ?? array();
543 $shipping = $row['shipping_groups'][0] ?? array();
544 $row['effective_tax_rate'] = $this->taxRateForClass(
545 (string)($primary['tax_class'] ?? ''),
546 (float)($primary['default_tax_rate'] ?? 19)
547 );
548 $row['effective_shipping_gross'] = (string)($row['shipping_mode'] ?? 'group') === 'individual' && (float)($row['shipping_gross'] ?? -1) >= 0
549 ? (float)$row['shipping_gross']
550 : (float)($shipping['shipping_gross'] ?? $primary['default_shipping_gross'] ?? 0);
551 $row['effective_shipping_way'] = (string)($shipping['shipping_way'] ?? '');
552 $row['effective_delivery_time'] = trim((string)($row['delivery_time'] ?? '')) !== ''
553 ? trim((string)$row['delivery_time'])
554 : trim((string)($shipping['delivery_time'] ?? ''));
555 return $row;
556 }
557
558 private function taxRatesConfig(): array {
559 $fallback = array(
560 'mwst1' => array('title' => 'MwSt. normal', 'rate' => '19'),
561 'mwst2' => array('title' => 'MwSt. ermaessigt', 'rate' => '7'),
562 'mwst3' => array('title' => 'MwSt. vorbereitet', 'rate' => '22'),
563 );
564 if (!function_exists('dbx')) {
565 return $fallback;
566 }
567 $cfg = dbx()->get_config('dbxShop', 'tax_rates', $fallback);
568 return is_array($cfg) && count($cfg) ? $cfg : $fallback;
569 }
570
571 private function taxRateForClass(string $taxClass, float $fallback): float {
572 $taxClass = trim($taxClass);
573 $rates = $this->taxRatesConfig();
574 if ($taxClass !== '' && isset($rates[$taxClass]) && is_array($rates[$taxClass])) {
575 return (float)($rates[$taxClass]['rate'] ?? $fallback);
576 }
577 $defaultClass = function_exists('dbx') ? (string)dbx()->get_config('dbxShop', 'default_tax_class', 'mwst1') : 'mwst1';
578 if (isset($rates[$defaultClass]) && is_array($rates[$defaultClass])) {
579 return (float)($rates[$defaultClass]['rate'] ?? $fallback);
580 }
581 return $fallback;
582 }
583
584 public function products(bool $activeOnly = true): array {
585 $this->install();
586 $where = $activeOnly ? 'active = 1 AND trash = 0' : 'trash = 0';
587 $rows = $this->db()->select($this->dd('shopProduct'), $where, '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
588 $rows = is_array($rows) ? $rows : array();
589 return array_map(fn($row) => $this->decorateProduct($row), $rows);
590 }
591
592 public function productBySku(string $sku, bool $activeOnly = true): ?array {
593 $this->install();
594 $where = 'sku = ' . $this->sqlValue($sku) . ' AND trash = 0';
595 if ($activeOnly) {
596 $where .= ' AND active = 1';
597 }
598 $row = $this->db()->select1($this->dd('shopProduct'), $where, '*', 0);
599 return is_array($row) ? $this->decorateProduct($row) : null;
600 }
601
602 public function productById(int $id): ?array {
603 $this->install();
604 $row = $this->db()->select1($this->dd('shopProduct'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
605 return is_array($row) ? $this->decorateProduct($row) : null;
606 }
607
608 public function groupById(int $id): ?array {
609 $this->install();
610 if ($id <= 0) {
611 return null;
612 }
613 $row = $this->db()->select1($this->dd('shopProductGroup'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
614 return is_array($row) ? $row : null;
615 }
616
617 private function stockEnabled(): bool {
618 $cfg = function_exists('dbx') ? dbx()->get_config('dbxShop', '', array()) : array();
619 return is_array($cfg) && !empty($cfg['stock_enabled']);
620 }
621
622 private function requiresStock(array $product): bool {
623 return $this->stockEnabled() && (string)($product['product_type'] ?? '') === 'physical';
624 }
625
626 private function isPhysicalProduct(array $product): bool {
627 return (string)($product['product_type'] ?? '') === 'physical';
628 }
629
630 public function stockIssuesForItems(array $items): array {
631 $this->install();
632 $issues = array();
633 foreach ($items as $sku => $qty) {
634 $qty = max(1, (int)$qty);
635 $product = $this->productBySku((string)$sku);
636 if (!$product || !$this->requiresStock($product)) {
637 continue;
638 }
639 $stock = (int)($product['stock'] ?? 0);
640 if ($stock < $qty) {
641 $issues[] = array(
642 'sku' => (string)($product['sku'] ?? $sku),
643 'title' => (string)($product['title'] ?? $sku),
644 'requested' => $qty,
645 'stock' => $stock,
646 );
647 }
648 }
649 return $issues;
650 }
651
652 private function hasReservableStockSnapshots(array $snapshots): bool {
653 if (!$this->stockEnabled()) {
654 return false;
655 }
656 foreach ($snapshots as $item) {
657 $productId = (int)($item['product_id'] ?? 0);
658 if ($productId <= 0) {
659 continue;
660 }
661 $product = $this->productById($productId);
662 if (!$product || !$this->requiresStock($product)) {
663 continue;
664 }
665 return true;
666 }
667 return false;
668 }
669
670 private function reserveStockForSnapshots(array $snapshots): int {
671 if (!$this->stockEnabled()) {
672 return 0;
673 }
674 $reserved = 0;
675 foreach ($snapshots as $item) {
676 $productId = (int)($item['product_id'] ?? 0);
677 if ($productId <= 0) {
678 continue;
679 }
680 $product = $this->productById($productId);
681 if (!$product || !$this->requiresStock($product)) {
682 continue;
683 }
684 $qty = max(1, (int)($item['qty'] ?? 1));
685 $stock = max(0, (int)($product['stock'] ?? 0) - $qty);
686 $this->db()->update($this->dd('shopProduct'), array(
687 'stock' => $stock,
688 'update_date' => date('Y-m-d H:i:s'),
689 ), 'id = ' . $productId . ' AND trash = 0', 0);
690 $reserved += $qty;
691 }
692 return $reserved;
693 }
694
695 private function releaseStockForOrder(array $order, string $reason): bool {
696 $orderId = (int)($order['id'] ?? 0);
697 if ($orderId <= 0 || (int)($order['stock_reserved'] ?? 0) !== 1 || (int)($order['stock_released'] ?? 0) === 1) {
698 return false;
699 }
700
701 $released = 0;
702 foreach ((array)($order['items'] ?? array()) as $item) {
703 $productId = (int)($item['product_id'] ?? 0);
704 if ($productId <= 0) {
705 continue;
706 }
707 $product = $this->productById($productId);
708 if (!$product || !$this->isPhysicalProduct($product)) {
709 continue;
710 }
711 $qty = max(1, (int)($item['qty'] ?? 1));
712 $stock = max(0, (int)($product['stock'] ?? 0)) + $qty;
713 $this->db()->update($this->dd('shopProduct'), array(
714 'stock' => $stock,
715 'update_date' => date('Y-m-d H:i:s'),
716 ), 'id = ' . $productId . ' AND trash = 0', 0);
717 $released += $qty;
718 }
719
720 if ($released <= 0) {
721 return false;
722 }
723
724 $this->db()->update($this->dd('shopOrder'), array(
725 'stock_released' => 1,
726 'stock_released_date' => date('Y-m-d H:i:s'),
727 'update_date' => date('Y-m-d H:i:s'),
728 ), 'id = ' . $orderId . ' AND trash = 0', 0);
729 $this->addOrderHistory($orderId, 'stock_release', '', (string)$released, $reason);
730 return true;
731 }
732
733 private function normalizeProductIds(array $ids): array {
734 $clean = array();
735 foreach ($ids as $id) {
736 $id = (int)$id;
737 if ($id > 0) {
738 $clean[$id] = $id;
739 }
740 }
741 return array_values($clean);
742 }
743
744 private function channelExists(string $channelKey): bool {
745 $channelKey = trim($channelKey);
746 if ($channelKey === '') {
747 return false;
748 }
749 return $this->db()->count($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($channelKey) . ' AND trash = 0') > 0;
750 }
751
752 private function productGroupExists(int $groupId): bool {
753 if ($groupId <= 0) {
754 return false;
755 }
756 return $this->db()->count($this->dd('shopProductGroup'), 'id = ' . (int)$groupId . ' AND trash = 0') > 0;
757 }
758
759 public function deleteProducts(array $ids): int {
760 $this->install();
761 $ids = $this->normalizeProductIds($ids);
762 if ($ids === array()) {
763 return 0;
764 }
765
766 $where = 'id IN (' . implode(',', array_map('intval', $ids)) . ') AND trash = 0';
767 $count = (int)$this->db()->count($this->dd('shopProduct'), $where);
768 if ($count <= 0) {
769 return 0;
770 }
771 $this->db()->update(
772 $this->dd('shopProduct'),
773 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
774 $where,
775 0
776 );
777 return $count;
778 }
779
780 public function addChannelToProducts(array $ids, string $channelKey): int {
781 $this->install();
782 $ids = $this->normalizeProductIds($ids);
783 $channelKey = trim($channelKey);
784 if ($ids === array() || !$this->channelExists($channelKey)) {
785 return 0;
786 }
787
788 $count = 0;
789 foreach ($ids as $id) {
790 $ok = $this->db()->save(
791 $this->dd('shopProductChannel'),
792 array('product_id' => $id, 'channel_key' => $channelKey, 'active' => 1, 'channel_sku' => '', 'price_gross' => -1, 'shipping_gross' => -1),
793 'product_id = ' . (int)$id . ' AND channel_key = ' . $this->sqlValue($channelKey),
794 0
795 );
796 if ($ok) {
797 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0);
798 $count++;
799 }
800 }
801 return $count;
802 }
803
804 public function removeChannelFromProducts(array $ids, string $channelKey): int {
805 $this->install();
806 $ids = $this->normalizeProductIds($ids);
807 $channelKey = trim($channelKey);
808 if ($ids === array() || !$this->channelExists($channelKey)) {
809 return 0;
810 }
811
812 $count = 0;
813 foreach ($ids as $id) {
814 $ok = $this->db()->save(
815 $this->dd('shopProductChannel'),
816 array('product_id' => $id, 'channel_key' => $channelKey, 'active' => 0, 'channel_sku' => '', 'price_gross' => -1, 'shipping_gross' => -1),
817 'product_id = ' . (int)$id . ' AND channel_key = ' . $this->sqlValue($channelKey),
818 0
819 );
820 if ($ok) {
821 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0);
822 $count++;
823 }
824 }
825 return $count;
826 }
827
828 public function setProductGroupForProducts(array $ids, int $groupId): int {
829 $this->install();
830 $ids = $this->normalizeProductIds($ids);
831 if ($ids === array() || !$this->productGroupExists($groupId)) {
832 return 0;
833 }
834
835 $count = 0;
836 foreach ($ids as $id) {
837 $this->db()->update($this->dd('shopProductGroupMap'), array('is_primary' => 0), 'product_id = ' . (int)$id, 0);
838 $ok = $this->db()->save(
839 $this->dd('shopProductGroupMap'),
840 array('product_id' => $id, 'group_id' => $groupId, 'is_primary' => 1),
841 'product_id = ' . (int)$id . ' AND group_id = ' . (int)$groupId,
842 0
843 );
844 if ($ok) {
845 $this->db()->update($this->dd('shopProduct'), array('product_group_id' => $groupId, 'update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0);
846 $count++;
847 }
848 }
849 return $count;
850 }
851
852 public function groupsForProduct(int $productId): array {
853 $product = $this->db()->select1($this->dd('shopProduct'), 'id = ' . (int)$productId . ' AND trash = 0', 'product_group_id', 0);
854 $directGroupId = (int)($product['product_group_id'] ?? 0);
855 if ($directGroupId > 0) {
856 $group = $this->groupById($directGroupId);
857 if (is_array($group)) {
858 return array($group);
859 }
860 }
861
862 $maps = $this->db()->select($this->dd('shopProductGroupMap'), 'product_id = ' . (int)$productId, '*', 'is_primary DESC', 'ASC', '', 0, 0, 0);
863 if (!is_array($maps) || $maps === array()) {
864 return array();
865 }
866 $groupIds = array_values(array_unique(array_map(fn($row) => (int)($row['group_id'] ?? 0), $maps)));
867 $groupIds = array_values(array_filter($groupIds, fn($id) => $id > 0));
868 if ($groupIds === array()) {
869 return array();
870 }
871 $groups = $this->db()->select($this->dd('shopProductGroup'), 'id IN (' . implode(',', $groupIds) . ') AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
872 $byId = array();
873 foreach ((is_array($groups) ? $groups : array()) as $group) {
874 $byId[(int)($group['id'] ?? 0)] = $group;
875 }
876 $rows = array();
877 foreach ($maps as $map) {
878 $id = (int)($map['group_id'] ?? 0);
879 if (isset($byId[$id])) {
880 $row = $byId[$id];
881 $row['_is_primary'] = (int)($map['is_primary'] ?? 0);
882 $rows[] = $row;
883 }
884 }
885 usort($rows, fn($a, $b) => ((int)($b['_is_primary'] ?? 0) <=> (int)($a['_is_primary'] ?? 0))
886 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
887 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
888 foreach ($rows as &$row) {
889 unset($row['_is_primary']);
890 }
891 unset($row);
892 return $rows;
893 }
894
895 public function channelsForProduct(int $productId): array {
896 $channelIndex = array();
897 foreach ($this->channels() as $channel) {
898 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
899 }
900
901 $channels = array();
902 $direct = $this->db()->select($this->dd('shopProductChannel'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
903 foreach ((is_array($direct) ? $direct : array()) as $row) {
904 $key = (string)($row['channel_key'] ?? '');
905 $base = $channelIndex[$key] ?? array('channel_key' => $key, 'title' => $key, 'sorter' => 9999);
906 $channels[] = array(
907 'channel_key' => $key,
908 'title' => (string)($base['title'] ?? $key),
909 'active' => (int)($row['active'] ?? 0),
910 'channel_sku' => (string)($row['channel_sku'] ?? ''),
911 'price_gross' => (float)($row['price_gross'] ?? -1),
912 'shipping_gross' => (float)($row['shipping_gross'] ?? -1),
913 '_sorter' => (int)($base['sorter'] ?? 9999),
914 );
915 }
916 usort($channels, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
917 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
918
919 $groupMaps = $this->db()->select($this->dd('shopProductChannelGroupMap'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
920 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
921 $groupId = (int)($groupMap['channel_group_id'] ?? 0);
922 if ($groupId <= 0) continue;
923 $groupChannels = $this->db()->select($this->dd('shopChannelGroupChannel'), 'channel_group_id = ' . $groupId . ' AND active = 1', '*', '', 'ASC', '', 0, 0, 0);
924 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
925 $key = (string)($row['channel_key'] ?? '');
926 $base = $channelIndex[$key] ?? array('channel_key' => $key, 'title' => $key, 'sorter' => 9999);
927 $channels[] = array(
928 'channel_key' => $key,
929 'title' => (string)($base['title'] ?? $key),
930 'active' => 1,
931 'channel_sku' => '',
932 'price_gross' => -1,
933 'shipping_gross' => -1,
934 '_sorter' => (int)($base['sorter'] ?? 9999),
935 );
936 }
937 }
938
939 $seen = array();
940 $clean = array();
941 foreach ($channels as $channel) {
942 $key = (string)($channel['channel_key'] ?? '');
943 if ($key === '' || isset($seen[$key])) continue;
944 $seen[$key] = true;
945 if ((int)($channel['active'] ?? 0) === 1) {
946 unset($channel['_sorter']);
947 $clean[] = $channel;
948 }
949 }
950 return $clean;
951 }
952
953 public function productChannelOverrides(int $productId): array {
954 $this->install();
955 $channelIndex = array();
956 foreach ($this->channels() as $channel) {
957 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
958 }
959 $rows = $this->db()->select($this->dd('shopProductChannel'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
960 $rows = is_array($rows) ? $rows : array();
961 foreach ($rows as &$row) {
962 $key = (string)($row['channel_key'] ?? '');
963 $row['title'] = (string)($channelIndex[$key]['title'] ?? $key);
964 $row['_sorter'] = (int)($channelIndex[$key]['sorter'] ?? 9999);
965 }
966 unset($row);
967 usort($rows, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
968 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
969 $out = array();
970 foreach ($rows as $row) {
971 $key = (string)($row['channel_key'] ?? '');
972 if ($key !== '') {
973 unset($row['_sorter']);
974 $out[$key] = $row;
975 }
976 }
977 return $out;
978 }
979
980 public function inheritedChannelsForProduct(int $productId): array {
981 $this->install();
982 $channelIndex = array();
983 foreach ($this->channels() as $channel) {
984 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
985 }
986 $groupMaps = $this->db()->select($this->dd('shopProductChannelGroupMap'), 'product_id = ' . (int)$productId, '*', '', 'ASC', '', 0, 0, 0);
987 $rows = array();
988 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
989 $groupId = (int)($groupMap['channel_group_id'] ?? 0);
990 if ($groupId <= 0) continue;
991 $group = $this->db()->select1($this->dd('shopChannelGroup'), 'id = ' . $groupId . ' AND trash = 0 AND active = 1', '*', 0);
992 if (!is_array($group)) continue;
993 $groupChannels = $this->db()->select($this->dd('shopChannelGroupChannel'), 'channel_group_id = ' . $groupId . ' AND active = 1', '*', '', 'ASC', '', 0, 0, 0);
994 foreach ((is_array($groupChannels) ? $groupChannels : array()) as $row) {
995 $key = (string)($row['channel_key'] ?? '');
996 $base = $channelIndex[$key] ?? array('channel_key' => $key, 'title' => $key, 'sorter' => 9999);
997 $rows[] = array(
998 'channel_key' => $key,
999 'title' => (string)($base['title'] ?? $key),
1000 'active' => (int)($row['active'] ?? 0),
1001 'group_title' => (string)($group['title'] ?? ''),
1002 '_sorter' => (int)($base['sorter'] ?? 9999),
1003 );
1004 }
1005 }
1006 usort($rows, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
1007 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
1008 $out = array();
1009 foreach ($rows as $row) {
1010 $key = (string)($row['channel_key'] ?? '');
1011 if ($key === '') {
1012 continue;
1013 }
1014 if (!isset($out[$key])) {
1015 $out[$key] = $row;
1016 $out[$key]['group_titles'] = array();
1017 }
1018 $groupTitle = trim((string)($row['group_title'] ?? ''));
1019 if ($groupTitle !== '') {
1020 $out[$key]['group_titles'][$groupTitle] = $groupTitle;
1021 }
1022 unset($out[$key]['_sorter']);
1023 }
1024 return $out;
1025 }
1026
1027 public function saveProductChannelOverrides(int $productId, array $activeChannelKeys): void {
1028 $this->install();
1029 $productId = max(0, $productId);
1030 if ($productId <= 0) {
1031 return;
1032 }
1033
1034 $product = $this->productById($productId);
1035 if (!$product) {
1036 return;
1037 }
1038
1039 $active = array();
1040 foreach ($activeChannelKeys as $key) {
1041 $key = trim((string)$key);
1042 if ($key !== '') {
1043 $active[$key] = true;
1044 }
1045 }
1046
1047 foreach ($this->channels() as $channel) {
1048 $key = trim((string)($channel['channel_key'] ?? ''));
1049 if ($key === '') {
1050 continue;
1051 }
1052 $existing = $this->db()->select1(
1053 $this->dd('shopProductChannel'),
1054 'product_id = ' . (int)$productId . ' AND channel_key = ' . $this->sqlValue($key),
1055 '*',
1056 0
1057 );
1058 $existing = is_array($existing) ? $existing : array();
1059 $channelSku = trim((string)($existing['channel_sku'] ?? ''));
1060 if ($channelSku === '') {
1061 $channelSku = (string)($product['sku'] ?? '');
1062 }
1063 $this->db()->save(
1064 $this->dd('shopProductChannel'),
1065 array(
1066 'product_id' => $productId,
1067 'channel_key' => $key,
1068 'active' => isset($active[$key]) ? 1 : 0,
1069 'channel_sku' => $channelSku,
1070 'price_gross' => (float)($existing['price_gross'] ?? -1),
1071 'shipping_gross' => (float)($existing['shipping_gross'] ?? -1),
1072 'external_listing_id' => (string)($existing['external_listing_id'] ?? ''),
1073 'external_offer_id' => (string)($existing['external_offer_id'] ?? ''),
1074 'export_status' => (string)($existing['export_status'] ?? ''),
1075 'export_message' => (string)($existing['export_message'] ?? ''),
1076 'export_payload' => (string)($existing['export_payload'] ?? ''),
1077 'last_export_date' => (string)($existing['last_export_date'] ?? ''),
1078 ),
1079 'product_id = ' . (int)$productId . ' AND channel_key = ' . $this->sqlValue($key),
1080 0
1081 );
1082 }
1083
1084 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$productId . ' AND trash = 0', 0);
1085 }
1086
1087 public function shippingGroupsForProduct(int $productId): array {
1088 $maps = $this->db()->select($this->dd('shopProductShippingGroupMap'), 'product_id = ' . (int)$productId, '*', 'is_primary DESC', 'ASC', '', 0, 0, 0);
1089 if (!is_array($maps) || $maps === array()) {
1090 return array();
1091 }
1092 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (int)($row['shipping_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1093 if ($ids === array()) {
1094 return array();
1095 }
1096 $groups = $this->db()->select($this->dd('shopShippingGroup'), 'id IN (' . implode(',', $ids) . ') AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
1097 $byId = array();
1098 foreach ((is_array($groups) ? $groups : array()) as $group) {
1099 $byId[(int)($group['id'] ?? 0)] = $group;
1100 }
1101 $rows = array();
1102 foreach ($maps as $map) {
1103 $id = (int)($map['shipping_group_id'] ?? 0);
1104 if (isset($byId[$id])) {
1105 $row = $byId[$id];
1106 $row['_is_primary'] = (int)($map['is_primary'] ?? 0);
1107 $rows[] = $row;
1108 }
1109 }
1110 usort($rows, fn($a, $b) => ((int)($b['_is_primary'] ?? 0) <=> (int)($a['_is_primary'] ?? 0))
1111 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
1112 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
1113 foreach ($rows as &$row) {
1114 unset($row['_is_primary']);
1115 }
1116 unset($row);
1117 return $rows;
1118 }
1119
1120 public function channelGroupsForProduct(int $productId): array {
1121 $maps = $this->db()->select($this->dd('shopProductChannelGroupMap'), 'product_id = ' . (int)$productId, '*', 'is_primary DESC', 'ASC', '', 0, 0, 0);
1122 if (!is_array($maps) || $maps === array()) {
1123 return array();
1124 }
1125 $ids = array_values(array_filter(array_unique(array_map(fn($row) => (int)($row['channel_group_id'] ?? 0), $maps)), fn($id) => $id > 0));
1126 if ($ids === array()) {
1127 return array();
1128 }
1129 $groups = $this->db()->select($this->dd('shopChannelGroup'), 'id IN (' . implode(',', $ids) . ') AND trash = 0', '*', '', 'ASC', '', 0, 0, 0);
1130 $byId = array();
1131 foreach ((is_array($groups) ? $groups : array()) as $group) {
1132 $byId[(int)($group['id'] ?? 0)] = $group;
1133 }
1134 $rows = array();
1135 foreach ($maps as $map) {
1136 $id = (int)($map['channel_group_id'] ?? 0);
1137 if (isset($byId[$id])) {
1138 $row = $byId[$id];
1139 $row['_is_primary'] = (int)($map['is_primary'] ?? 0);
1140 $rows[] = $row;
1141 }
1142 }
1143 usort($rows, fn($a, $b) => ((int)($b['_is_primary'] ?? 0) <=> (int)($a['_is_primary'] ?? 0))
1144 ?: ((int)($a['sorter'] ?? 0) <=> (int)($b['sorter'] ?? 0))
1145 ?: strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')));
1146 foreach ($rows as &$row) {
1147 unset($row['_is_primary']);
1148 }
1149 unset($row);
1150 return $rows;
1151 }
1152
1153 public function groups(): array {
1154 $this->install();
1155 $rows = $this->db()->select($this->dd('shopProductGroup'), 'trash = 0', '*', 'parent_id ASC, sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1156 return is_array($rows) ? $rows : array();
1157 }
1158
1159 public function groupsByParent(int $parentId = 0, bool $activeOnly = true): array {
1160 $this->install();
1161 $where = 'trash = 0 AND parent_id = ' . max(0, (int)$parentId);
1162 if ($activeOnly) {
1163 $where .= ' AND active = 1';
1164 }
1165 $rows = $this->db()->select($this->dd('shopProductGroup'), $where, '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1166 return is_array($rows) ? $rows : array();
1167 }
1168
1169 public function groupPath(int $groupId): array {
1170 $this->install();
1171 $path = array();
1172 $seen = array();
1173 $current = max(0, (int)$groupId);
1174 while ($current > 0 && !isset($seen[$current])) {
1175 $seen[$current] = true;
1176 $group = $this->groupById($current);
1177 if (!is_array($group)) {
1178 break;
1179 }
1180 array_unshift($path, $group);
1181 $current = (int)($group['parent_id'] ?? 0);
1182 }
1183 return $path;
1184 }
1185
1186 private function wouldCreateGroupCycle(int $groupId, int $parentId): bool {
1187 if ($groupId <= 0 || $parentId <= 0) {
1188 return false;
1189 }
1190 if ($groupId === $parentId) {
1191 return true;
1192 }
1193 $seen = array($groupId => true);
1194 $current = $parentId;
1195 while ($current > 0 && !isset($seen[$current])) {
1196 $seen[$current] = true;
1197 $group = $this->groupById($current);
1198 if (!is_array($group)) {
1199 return false;
1200 }
1201 $current = (int)($group['parent_id'] ?? 0);
1202 if ($current === $groupId) {
1203 return true;
1204 }
1205 }
1206 return false;
1207 }
1208
1209 private function nextGroupSorter(int $parentId): int {
1210 $rows = $this->db()->select(
1211 $this->dd('shopProductGroup'),
1212 'trash = 0 AND parent_id = ' . max(0, (int)$parentId),
1213 'sorter',
1214 'sorter',
1215 'DESC',
1216 '',
1217 0,
1218 1,
1219 0
1220 );
1221 $max = is_array($rows) && isset($rows[0]) ? (int)($rows[0]['sorter'] ?? 0) : 0;
1222 return $max + 10;
1223 }
1224
1225 public function moveProductGroupParent(int $groupId, int $parentId): bool {
1226 $this->install();
1227 $groupId = max(0, (int)$groupId);
1228 $parentId = max(0, (int)$parentId);
1229 if ($groupId <= 0 || !is_array($this->groupById($groupId))) {
1230 return false;
1231 }
1232 if ($parentId > 0 && !is_array($this->groupById($parentId))) {
1233 return false;
1234 }
1235 if ($this->wouldCreateGroupCycle($groupId, $parentId)) {
1236 return false;
1237 }
1238 $this->db()->update(
1239 $this->dd('shopProductGroup'),
1240 array(
1241 'parent_id' => $parentId,
1242 'sorter' => $this->nextGroupSorter($parentId),
1243 'update_date' => date('Y-m-d H:i:s'),
1244 ),
1245 'id = ' . $groupId . ' AND trash = 0',
1246 0
1247 );
1248 return true;
1249 }
1250
1251 public function channels(): array {
1252 $this->install();
1253 $rows = $this->db()->select($this->dd('shopChannel'), 'trash = 0', '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1254 return is_array($rows) ? $rows : array();
1255 }
1256
1257 public function channelById(int $id): ?array {
1258 $this->install();
1259 if ($id <= 0) {
1260 return null;
1261 }
1262 $row = $this->db()->select1($this->dd('shopChannel'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
1263 return is_array($row) ? $row : null;
1264 }
1265
1266 public function channelByKey(string $key): ?array {
1267 $this->install();
1268 $key = trim($key);
1269 if ($key === '') {
1270 return null;
1271 }
1272 $row = $this->db()->select1($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($key) . ' AND trash = 0', '*', 0);
1273 return is_array($row) ? $row : null;
1274 }
1275
1276 public function updateChannel(int $id, array $data): void {
1277 $this->install();
1278 $secretFields = array('api_client_secret', 'api_access_token', 'api_refresh_token', 'api_password', 'webhook_secret');
1279 $existing = $id > 0 ? ($this->channelById($id) ?: array()) : array();
1280 $values = array(
1281 'title' => (string)($data['title'] ?? ''),
1282 'description' => (string)($data['description'] ?? ''),
1283 'platform_type' => (string)($data['platform_type'] ?? 'custom'),
1284 'connection_mode' => (string)($data['connection_mode'] ?? 'manual'),
1285 'api_base_url' => (string)($data['api_base_url'] ?? ''),
1286 'api_client_id' => (string)($data['api_client_id'] ?? ''),
1287 'api_username' => (string)($data['api_username'] ?? ''),
1288 'marketplace_id' => (string)($data['marketplace_id'] ?? ''),
1289 'seller_id' => (string)($data['seller_id'] ?? ''),
1290 'account_id' => (string)($data['account_id'] ?? ''),
1291 'location_key' => (string)($data['location_key'] ?? ''),
1292 'category_id' => (string)($data['category_id'] ?? ''),
1293 'payment_policy_id' => (string)($data['payment_policy_id'] ?? ''),
1294 'fulfillment_policy_id' => (string)($data['fulfillment_policy_id'] ?? ''),
1295 'return_policy_id' => (string)($data['return_policy_id'] ?? ''),
1296 'notification_destination' => (string)($data['notification_destination'] ?? ''),
1297 'notification_topic' => (string)($data['notification_topic'] ?? ''),
1298 'api_scope' => (string)($data['api_scope'] ?? ''),
1299 'export_enabled' => !empty($data['export_enabled']) ? 1 : 0,
1300 'order_import_enabled' => !empty($data['order_import_enabled']) ? 1 : 0,
1301 'active' => !empty($data['active']) ? 1 : 0,
1302 'sorter' => (int)($data['sorter'] ?? 100),
1303 );
1304 foreach ($secretFields as $field) {
1305 $posted = (string)($data[$field] ?? '');
1306 $values[$field] = ($id > 0 && $posted === '') ? (string)($existing[$field] ?? '') : $posted;
1307 }
1308
1309 if ($id <= 0) {
1310 $channelKey = $this->normalizeKey((string)($data['channel_key'] ?? ''));
1311 if ($channelKey === '') {
1312 $channelKey = $this->normalizeKey((string)($data['title'] ?? 'channel'));
1313 }
1314 if ($channelKey === '') $channelKey = 'channel';
1315 $channelKey = $this->uniqueChannelKey($channelKey);
1316 $values['channel_key'] = $channelKey;
1317 $this->db()->insert($this->dd('shopChannel'), $values, 0);
1318 return;
1319 }
1320
1321 $this->db()->update($this->dd('shopChannel'), $values, 'id = ' . (int)$id, 0);
1322 }
1323
1324 public function deleteChannel(int $id): int {
1325 $this->install();
1326 if ($id <= 0) {
1327 return 0;
1328 }
1329
1330 $channel = $this->channelById($id);
1331 if (!$channel) {
1332 return 0;
1333 }
1334
1335 $updated = (int)$this->db()->update(
1336 $this->dd('shopChannel'),
1337 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
1338 'id = ' . (int)$id . ' AND trash = 0',
1339 0
1340 );
1341 if ($updated !== 1) {
1342 return 0;
1343 }
1344
1345 $this->db()->update(
1346 $this->dd('shopProductChannel'),
1347 array('active' => 0),
1348 'channel_key = ' . $this->sqlValue((string)($channel['channel_key'] ?? '')),
1349 0
1350 );
1351 return 1;
1352 }
1353
1354 public function testChannelConnection(int $id): array {
1355 $this->install();
1356 $channel = $this->channelById($id);
1357 if (!$channel) {
1358 return array('ok' => false, 'message' => 'Channel wurde nicht gefunden.');
1359 }
1360
1361 $connector = dbx()->get_include_obj('dbxShopChannelConnector', 'dbxShop');
1362 $result = is_object($connector) && method_exists($connector, 'test')
1363 ? (array)$connector->test($channel)
1364 : array('ok' => false, 'message' => 'Channel-Connector konnte nicht geladen werden.');
1365
1366 $ok = !empty($result['ok']);
1367 $message = (string)($result['message'] ?? '');
1368 $this->saveChannelTestResult($id, $ok, $message);
1369 return array('ok' => $ok, 'message' => $message);
1370 }
1371
1372 private function productHasActiveChannel(array $product, string $channelKey): bool {
1373 foreach ((array)($product['channels'] ?? array()) as $channel) {
1374 if ((string)($channel['channel_key'] ?? '') === $channelKey && (int)($channel['active'] ?? 0) === 1) {
1375 return true;
1376 }
1377 }
1378 return false;
1379 }
1380
1381 private function productChannelRowForExport(array $product, string $channelKey): array {
1382 $productId = (int)($product['id'] ?? 0);
1383 $row = $this->db()->select1(
1384 $this->dd('shopProductChannel'),
1385 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
1386 '*',
1387 0
1388 );
1389 if (is_array($row)) {
1390 return $row;
1391 }
1392
1393 $values = array(
1394 'product_id' => $productId,
1395 'channel_key' => $channelKey,
1396 'active' => 1,
1397 'channel_sku' => (string)($product['sku'] ?? ''),
1398 'price_gross' => -1,
1399 'shipping_gross' => -1,
1400 'export_status' => 'ready',
1401 );
1402 $this->db()->save(
1403 $this->dd('shopProductChannel'),
1404 $values,
1405 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
1406 0
1407 );
1408 $row = $this->db()->select1(
1409 $this->dd('shopProductChannel'),
1410 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
1411 '*',
1412 0
1413 );
1414 return is_array($row) ? $row : $values;
1415 }
1416
1417 public function productChannelMapping(int $productId, string $channelKey): ?array {
1418 $this->install();
1419 $product = $this->productById($productId);
1420 if (!$product) {
1421 return null;
1422 }
1423 $channel = $this->channelByKey($channelKey);
1424 if (!$channel) {
1425 return null;
1426 }
1427 $row = $this->db()->select1(
1428 $this->dd('shopProductChannel'),
1429 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
1430 '*',
1431 0
1432 );
1433 if (!is_array($row)) {
1434 $row = array(
1435 'product_id' => $productId,
1436 'channel_key' => $channelKey,
1437 'active' => $this->productHasActiveChannel($product, $channelKey) ? 1 : 0,
1438 'channel_sku' => (string)($product['sku'] ?? ''),
1439 'price_gross' => -1,
1440 'shipping_gross' => -1,
1441 'note' => '',
1442 );
1443 }
1444 $note = trim((string)($row['note'] ?? ''));
1445 $mapping = $note !== '' ? json_decode($note, true) : array();
1446 if (!is_array($mapping)) {
1447 $mapping = array();
1448 }
1449 return array(
1450 'product' => $product,
1451 'channel' => $channel,
1452 'product_channel' => $row,
1453 'mapping' => $mapping,
1454 );
1455 }
1456
1457 public function saveProductChannelMapping(int $productId, string $channelKey, array $data): void {
1458 $this->install();
1459 $product = $this->productById($productId);
1460 $channel = $this->channelByKey($channelKey);
1461 if (!$product || !$channel) {
1462 return;
1463 }
1464
1465 $mapping = is_array($data['mapping'] ?? null) ? $data['mapping'] : array();
1466 $note = json_encode($mapping, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
1467 if ($note === false) {
1468 $note = '';
1469 }
1470
1471 $existing = $this->productChannelRowForExport($product, $channelKey);
1472 $this->db()->save(
1473 $this->dd('shopProductChannel'),
1474 array(
1475 'product_id' => $productId,
1476 'channel_key' => $channelKey,
1477 'active' => !empty($data['active']) ? 1 : 0,
1478 'channel_sku' => trim((string)($data['channel_sku'] ?? $product['sku'] ?? '')),
1479 'price_gross' => (float)($data['price_gross'] ?? -1),
1480 'shipping_gross' => (float)($data['shipping_gross'] ?? -1),
1481 'external_listing_id' => trim((string)($data['external_listing_id'] ?? $existing['external_listing_id'] ?? '')),
1482 'external_offer_id' => trim((string)($data['external_offer_id'] ?? $existing['external_offer_id'] ?? '')),
1483 'export_status' => (string)($existing['export_status'] ?? ''),
1484 'export_message' => (string)($existing['export_message'] ?? ''),
1485 'export_payload' => (string)($existing['export_payload'] ?? ''),
1486 'last_export_date' => (string)($existing['last_export_date'] ?? ''),
1487 'note' => $note,
1488 ),
1489 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
1490 0
1491 );
1492 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . $productId . ' AND trash = 0', 0);
1493 }
1494
1495 private function saveProductChannelExportResult(int $productId, string $channelKey, array $result): void {
1496 $payload = $result['payload'] ?? array();
1497 $payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1498 if ($payloadJson === false) {
1499 $payloadJson = '';
1500 }
1501 $this->db()->save(
1502 $this->dd('shopProductChannel'),
1503 array(
1504 'product_id' => $productId,
1505 'channel_key' => $channelKey,
1506 'active' => 1,
1507 'external_listing_id' => (string)($result['external_listing_id'] ?? ''),
1508 'external_offer_id' => (string)($result['external_offer_id'] ?? ''),
1509 'export_status' => (string)($result['status'] ?? (!empty($result['ok']) ? 'exported' : 'failed')),
1510 'export_message' => (string)($result['message'] ?? ''),
1511 'export_payload' => $payloadJson,
1512 'last_export_date' => date('Y-m-d H:i:s'),
1513 ),
1514 'product_id = ' . $productId . ' AND channel_key = ' . $this->sqlValue($channelKey),
1515 0
1516 );
1517 $this->db()->update($this->dd('shopProduct'), array('update_date' => date('Y-m-d H:i:s')), 'id = ' . $productId . ' AND trash = 0', 0);
1518 }
1519
1520 public function exportProductToChannel(int $productId, string $channelKey): array {
1521 $this->install();
1522 $channelKey = trim($channelKey);
1523 $product = $this->productById($productId);
1524 if (!$product) {
1525 return array('ok' => false, 'status' => 'failed', 'message' => 'Artikel wurde nicht gefunden.');
1526 }
1527 $channel = $this->channelByKey($channelKey);
1528 if (!$channel || (int)($channel['active'] ?? 0) !== 1) {
1529 return array('ok' => false, 'status' => 'failed', 'message' => 'Channel ist nicht aktiv oder wurde nicht gefunden.');
1530 }
1531 if ((int)($channel['export_enabled'] ?? 0) !== 1) {
1532 return array('ok' => false, 'status' => 'failed', 'message' => 'Export ist fuer diesen Channel nicht aktiv.');
1533 }
1534 if (!$this->productHasActiveChannel($product, $channelKey)) {
1535 return array('ok' => false, 'status' => 'failed', 'message' => 'Artikel ist diesem Channel nicht aktiv zugeordnet.');
1536 }
1537
1538 $productChannel = $this->productChannelRowForExport($product, $channelKey);
1539 $connector = dbx()->get_include_obj('dbxShopChannelConnector', 'dbxShop');
1540 $result = is_object($connector) && method_exists($connector, 'exportProduct')
1541 ? (array)$connector->exportProduct($channel, $product, $productChannel)
1542 : array('ok' => false, 'status' => 'failed', 'message' => 'Channel-Export-Connector konnte nicht geladen werden.');
1543 $this->saveProductChannelExportResult((int)$product['id'], $channelKey, $result);
1544 return $result;
1545 }
1546
1547 public function exportProductsToChannel(array $ids, string $channelKey): array {
1548 $this->install();
1549 $ids = $this->normalizeProductIds($ids);
1550 $summary = array('total' => count($ids), 'ok' => 0, 'failed' => 0, 'messages' => array());
1551 foreach ($ids as $id) {
1552 $result = $this->exportProductToChannel($id, $channelKey);
1553 if (!empty($result['ok'])) {
1554 $summary['ok']++;
1555 } else {
1556 $summary['failed']++;
1557 }
1558 $message = trim((string)($result['message'] ?? ''));
1559 if ($message !== '') {
1560 $summary['messages'][] = '#' . $id . ': ' . $message;
1561 }
1562 }
1563 return $summary;
1564 }
1565
1566 private function saveChannelTestResult(int $id, bool $ok, string $message): void {
1567 $now = date('Y-m-d H:i:s');
1568 $this->db()->update(
1569 $this->dd('shopChannel'),
1570 array(
1571 'test_status' => $ok ? 'ok' : 'error',
1572 'test_message' => $message,
1573 'last_test_date' => $now,
1574 'update_date' => $now,
1575 ),
1576 'id = ' . (int)$id,
1577 0
1578 );
1579 }
1580
1581 public function shippingGroups(): array {
1582 $this->install();
1583 $rows = $this->db()->select($this->dd('shopShippingGroup'), 'trash = 0', '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1584 return is_array($rows) ? $rows : array();
1585 }
1586
1587 public function channelGroups(): array {
1588 $this->install();
1589 $groups = $this->db()->select($this->dd('shopChannelGroup'), 'trash = 0', '*', 'sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1590 $groups = is_array($groups) ? $groups : array();
1591 foreach ($groups as &$group) {
1592 $group['channels'] = $this->channelsForChannelGroup((int)$group['id']);
1593 }
1594 unset($group);
1595 return $groups;
1596 }
1597
1598 public function channelsForChannelGroup(int $channelGroupId): array {
1599 $channelIndex = array();
1600 foreach ($this->channels() as $channel) {
1601 $channelIndex[(string)($channel['channel_key'] ?? '')] = $channel;
1602 }
1603 $rows = $this->db()->select($this->dd('shopChannelGroupChannel'), 'channel_group_id = ' . (int)$channelGroupId, '*', '', 'ASC', '', 0, 0, 0);
1604 $rows = is_array($rows) ? $rows : array();
1605 foreach ($rows as &$row) {
1606 $key = (string)($row['channel_key'] ?? '');
1607 $row['title'] = (string)($channelIndex[$key]['title'] ?? $key);
1608 $row['_sorter'] = (int)($channelIndex[$key]['sorter'] ?? 9999);
1609 }
1610 unset($row);
1611 usort($rows, fn($a, $b) => ((int)($a['_sorter'] ?? 9999) <=> (int)($b['_sorter'] ?? 9999))
1612 ?: strcasecmp((string)($a['channel_key'] ?? ''), (string)($b['channel_key'] ?? '')));
1613 foreach ($rows as &$row) {
1614 unset($row['_sorter']);
1615 }
1616 unset($row);
1617 return $rows;
1618 }
1619
1620 public function updateProductGroup(int $id, array $data): void {
1621 $this->install();
1622 $parentId = max(0, (int)($data['parent_id'] ?? 0));
1623 if ($id > 0 && $parentId === $id) {
1624 $parentId = 0;
1625 }
1626 $values = array(
1627 'parent_id' => $parentId,
1628 'title' => (string)($data['title'] ?? ''),
1629 'description' => (string)($data['description'] ?? ''),
1630 'tax_class' => (string)($data['tax_class'] ?? 'mwst1'),
1631 'default_tax_rate' => $this->taxRateForClass((string)($data['tax_class'] ?? 'mwst1'), (float)($data['default_tax_rate'] ?? 19)),
1632 'display_variant' => (string)($data['display_variant'] ?? 'gallery_grid'),
1633 'card_template' => (string)($data['card_template'] ?? 'product-card-default'),
1634 'detail_template' => (string)($data['detail_template'] ?? 'product-detail-default'),
1635 'gallery_template' => (string)($data['gallery_template'] ?? 'image-gallery'),
1636 'gallery_visible_count' => max(1, (int)($data['gallery_visible_count'] ?? 3)),
1637 'gallery_image_size' => (string)($data['gallery_image_size'] ?? 'original'),
1638 'gallery_lightbox_width' => (string)($data['gallery_lightbox_width'] ?? '100vw'),
1639 'gallery_overflow' => (string)($data['gallery_overflow'] ?? 'grid'),
1640 'gallery_click' => (string)($data['gallery_click'] ?? 'lightbox'),
1641 'attribute_notes' => (string)($data['attribute_notes'] ?? ''),
1642 'ebay_category_id' => (string)($data['ebay_category_id'] ?? ''),
1643 'amazon_product_type' => (string)($data['amazon_product_type'] ?? ''),
1644 'kleinanzeigen_category_id' => (string)($data['kleinanzeigen_category_id'] ?? ''),
1645 'mobile_category_id' => (string)($data['mobile_category_id'] ?? ''),
1646 'active' => !empty($data['active']) ? 1 : 0,
1647 'sorter' => (int)($data['sorter'] ?? 100),
1648 );
1649 if ($id <= 0) {
1650 $groupKey = $this->normalizeKey((string)($data['group_key'] ?? ''));
1651 if ($groupKey === '') {
1652 $groupKey = $this->normalizeKey((string)($data['title'] ?? 'artikelgruppe'));
1653 }
1654 if ($groupKey === '') $groupKey = 'artikelgruppe';
1655 $groupKey = $this->uniqueProductGroupKey($groupKey);
1656 $values['group_key'] = $groupKey;
1657 $this->db()->insert($this->dd('shopProductGroup'), $values, 0);
1658 return;
1659 }
1660
1661 $this->db()->update($this->dd('shopProductGroup'), $values, 'id = ' . (int)$id, 0);
1662 }
1663
1664 public function updateShippingGroup(int $id, array $data): void {
1665 $this->install();
1666 $values = array(
1667 'title' => (string)($data['title'] ?? ''),
1668 'description' => (string)($data['description'] ?? ''),
1669 'shipping_way' => (string)($data['shipping_way'] ?? ''),
1670 'delivery_time' => (string)($data['delivery_time'] ?? ''),
1671 'shipping_gross' => (float)($data['shipping_gross'] ?? 0),
1672 'free_from_gross' => (float)($data['free_from_gross'] ?? -1),
1673 'active' => !empty($data['active']) ? 1 : 0,
1674 'sorter' => (int)($data['sorter'] ?? 100),
1675 );
1676 if ($id <= 0) {
1677 $groupKey = $this->normalizeKey((string)($data['group_key'] ?? ''));
1678 if ($groupKey === '') {
1679 $groupKey = $this->normalizeKey((string)($data['title'] ?? 'versandgruppe'));
1680 }
1681 if ($groupKey === '') $groupKey = 'versandgruppe';
1682 $groupKey = $this->uniqueShippingGroupKey($groupKey);
1683 $values['group_key'] = $groupKey;
1684 $this->db()->insert($this->dd('shopShippingGroup'), $values, 0);
1685 return;
1686 }
1687
1688 $this->db()->update($this->dd('shopShippingGroup'), $values, 'id = ' . (int)$id, 0);
1689 }
1690
1691 public function updateChannelGroup(int $id, array $data, array $channelKeys): void {
1692 $this->install();
1693 $values = array(
1694 'title' => (string)($data['title'] ?? ''),
1695 'description' => (string)($data['description'] ?? ''),
1696 'active' => !empty($data['active']) ? 1 : 0,
1697 'sorter' => (int)($data['sorter'] ?? 100),
1698 );
1699 if ($id <= 0) {
1700 $groupKey = $this->normalizeKey((string)($data['group_key'] ?? ''));
1701 if ($groupKey === '') {
1702 $groupKey = $this->normalizeKey((string)($data['title'] ?? 'channel-gruppe'));
1703 }
1704 if ($groupKey === '') $groupKey = 'channel-gruppe';
1705 $groupKey = $this->uniqueChannelGroupKey($groupKey);
1706 $values['group_key'] = $groupKey;
1707 if ($values['title'] === '') {
1708 $values['title'] = $groupKey;
1709 }
1710 $this->db()->insert($this->dd('shopChannelGroup'), $values, 0);
1711 $row = $this->db()->select1($this->dd('shopChannelGroup'), 'group_key = ' . $this->sqlValue($groupKey), 'id', 0);
1712 $id = (int)($row['id'] ?? 0);
1713 } else {
1714 $this->db()->update($this->dd('shopChannelGroup'), $values, 'id = ' . (int)$id, 0);
1715 }
1716
1717 if ($id <= 0) {
1718 return;
1719 }
1720 $channels = $this->channels();
1721 foreach ($channels as $channel) {
1722 $key = (string)($channel['channel_key'] ?? '');
1723 if ($key === '') continue;
1724 $active = in_array($key, $channelKeys, true) ? 1 : 0;
1725 $this->db()->save(
1726 $this->dd('shopChannelGroupChannel'),
1727 array('channel_group_id' => $id, 'channel_key' => $key, 'active' => $active),
1728 'channel_group_id = ' . (int)$id . ' AND channel_key = ' . $this->sqlValue($key),
1729 0
1730 );
1731 }
1732 }
1733
1734 public function deleteChannelGroup(int $id): int {
1735 $this->install();
1736 if ($id <= 0) {
1737 return 0;
1738 }
1739
1740 $updated = (int)$this->db()->update(
1741 $this->dd('shopChannelGroup'),
1742 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
1743 'id = ' . (int)$id . ' AND trash = 0',
1744 0
1745 );
1746 if ($updated !== 1) {
1747 return 0;
1748 }
1749
1750 $this->db()->update($this->dd('shopChannelGroupChannel'), array('active' => 0), 'channel_group_id = ' . (int)$id, 0);
1751 return 1;
1752 }
1753
1754 public function deleteProductGroup(int $id): int {
1755 $this->install();
1756 if ($id <= 0) {
1757 return 0;
1758 }
1759
1760 return (int)$this->db()->update(
1761 $this->dd('shopProductGroup'),
1762 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
1763 'id = ' . (int)$id . ' AND trash = 0',
1764 0
1765 );
1766 }
1767
1768 public function deleteShippingGroup(int $id): int {
1769 $this->install();
1770 if ($id <= 0) {
1771 return 0;
1772 }
1773
1774 return (int)$this->db()->update(
1775 $this->dd('shopShippingGroup'),
1776 array('trash' => 1, 'active' => 0, 'update_date' => date('Y-m-d H:i:s')),
1777 'id = ' . (int)$id . ' AND trash = 0',
1778 0
1779 );
1780 }
1781
1782 private function normalizeKey(string $value): string {
1783 $value = strtolower(trim($value));
1784 $value = preg_replace('~[^a-z0-9_-]+~', '-', $value);
1785 $value = trim((string)$value, '-_');
1786 return $value;
1787 }
1788
1789 private function uniqueProductGroupKey(string $baseKey): string {
1790 return $this->uniqueKey('shop_product_group', $baseKey);
1791 }
1792
1793 private function uniqueShippingGroupKey(string $baseKey): string {
1794 return $this->uniqueKey('shop_shipping_group', $baseKey);
1795 }
1796
1797 private function uniqueChannelKey(string $baseKey): string {
1798 $key = $baseKey !== '' ? $baseKey : 'channel';
1799 $suffix = 2;
1800 while (true) {
1801 if ($this->db()->count($this->dd('shopChannel'), 'channel_key = ' . $this->sqlValue($key)) <= 0) return $key;
1802 $key = $baseKey . '-' . $suffix;
1803 $suffix++;
1804 }
1805 }
1806
1807 private function uniqueChannelGroupKey(string $baseKey): string {
1808 return $this->uniqueKey('shop_channel_group', $baseKey);
1809 }
1810
1811 private function uniqueKey(string $table, string $baseKey): string {
1812 $ddMap = array(
1813 'shop_product_group' => $this->dd('shopProductGroup'),
1814 'shop_shipping_group' => $this->dd('shopShippingGroup'),
1815 'shop_channel_group' => $this->dd('shopChannelGroup'),
1816 );
1817 if (!isset($ddMap[$table])) {
1818 return $baseKey;
1819 }
1820 $key = $baseKey !== '' ? $baseKey : 'gruppe';
1821 $suffix = 2;
1822 while (true) {
1823 if ($this->db()->count($ddMap[$table], 'group_key = ' . $this->sqlValue($key)) <= 0) return $key;
1824 $key = $baseKey . '-' . $suffix;
1825 $suffix++;
1826 }
1827 }
1828
1829 public function imagesForProduct(int $productId, array $groups = array()): array {
1830 $this->install();
1831 $images = array();
1832 if ($productId > 0) {
1833 $rows = $this->db()->select(
1834 $this->dd('shopProductImage'),
1835 'trash = 0 AND active = 1 AND product_id = ' . (int)$productId,
1836 '*',
1837 'is_primary DESC, sorter ASC, title ASC',
1838 'ASC',
1839 '',
1840 0,
1841 0,
1842 0
1843 );
1844 $images = is_array($rows) ? $rows : array();
1845 }
1846
1847 $groupIds = array();
1848 foreach ($groups as $group) {
1849 $id = (int)($group['id'] ?? 0);
1850 if ($id > 0) $groupIds[] = $id;
1851 }
1852 if ($groupIds !== array()) {
1853 $rows = $this->db()->select(
1854 $this->dd('shopProductImage'),
1855 'trash = 0 AND active = 1 AND group_id IN (' . implode(',', array_map('intval', $groupIds)) . ')',
1856 '*',
1857 'is_primary DESC, sorter ASC, title ASC',
1858 'ASC',
1859 '',
1860 0,
1861 0,
1862 0
1863 );
1864 $images = array_merge($images, is_array($rows) ? $rows : array());
1865 }
1866
1867 $seen = array();
1868 $clean = array();
1869 foreach ($images as $image) {
1870 $path = (string)($image['image_path'] ?? '');
1871 $mediaId = (int)($image['media_id'] ?? 0);
1872 $key = $mediaId > 0 ? 'm:' . $mediaId : 'p:' . $path;
1873 if (($mediaId <= 0 && $path === '') || isset($seen[$key])) continue;
1874 $seen[$key] = true;
1875 $clean[] = $image;
1876 }
1877 return $clean;
1878 }
1879
1880 public function productImageCounts(): array {
1881 $this->install();
1882 $counts = array();
1883 $products = $this->db()->select($this->dd('shopProduct'), 'trash = 0', 'id', '', 'ASC', '', 0, 0, 0);
1884 foreach ((is_array($products) ? $products : array()) as $product) {
1885 $productId = (int)($product['id'] ?? 0);
1886 if ($productId <= 0) continue;
1887 $groups = $this->groupsForProduct($productId);
1888 $counts[$productId] = count($this->imagesForProduct($productId, $groups));
1889 }
1890 return $counts;
1891 }
1892
1893 public function allImages(): array {
1894 $this->install();
1895 $rows = $this->db()->select($this->dd('shopProductImage'), 'trash = 0', '*', 'active DESC, product_id DESC, group_id DESC, sorter ASC, title ASC', 'ASC', '', 0, 0, 0);
1896 $rows = is_array($rows) ? $rows : array();
1897 foreach ($rows as &$row) {
1898 $productId = (int)($row['product_id'] ?? 0);
1899 $groupId = (int)($row['group_id'] ?? 0);
1900 $product = $productId > 0 ? $this->db()->select1($this->dd('shopProduct'), 'id = ' . $productId, 'title', 0) : array();
1901 $group = $groupId > 0 ? $this->db()->select1($this->dd('shopProductGroup'), 'id = ' . $groupId, 'title', 0) : array();
1902 $row['product_title'] = is_array($product) ? (string)($product['title'] ?? '') : '';
1903 $row['group_title'] = is_array($group) ? (string)($group['title'] ?? '') : '';
1904 }
1905 unset($row);
1906 return $rows;
1907 }
1908
1909 public function updateImageMediaReference(int $imageId, int $mediaId, string $imagePath = ''): void {
1910 $this->install();
1911 $imageId = max(0, $imageId);
1912 $mediaId = max(0, $mediaId);
1913 if ($imageId <= 0 || $mediaId <= 0) {
1914 return;
1915 }
1916
1917 $imagePath = trim(str_replace('\\', '/', $imagePath));
1918 if ($imagePath === '') {
1919 $imagePath = 'dbxmedia:' . $mediaId;
1920 }
1921
1922 $this->db()->update(
1923 $this->dd('shopProductImage'),
1924 array('media_id' => $mediaId, 'image_path' => $imagePath),
1925 'id = ' . (int)$imageId,
1926 0
1927 );
1928 }
1929
1930 public function saveImage(int $productId, int $groupId, string $imagePath, string $title, string $alt, int $isPrimary = 0, int $sorter = 100): void {
1931 $this->install();
1932 $productId = max(0, $productId);
1933 $groupId = max(0, $groupId);
1934 $imagePath = trim(str_replace('\\', '/', $imagePath));
1935 if ($imagePath === '' || ($productId <= 0 && $groupId <= 0)) {
1936 return;
1937 }
1938 if ($groupId > 0 && $productId <= 0) {
1939 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), 'group_id = ' . (int)$groupId . ' AND product_id = 0 AND trash = 0', 0);
1940 $isPrimary = 1;
1941 }
1942 $this->db()->save(
1943 $this->dd('shopProductImage'),
1944 array(
1945 'product_id' => $productId,
1946 'group_id' => $groupId,
1947 'image_path' => $imagePath,
1948 'title' => $title,
1949 'alt' => $alt,
1950 'is_primary' => $isPrimary,
1951 'active' => 1,
1952 'sorter' => $sorter,
1953 ),
1954 'product_id = ' . (int)$productId . ' AND group_id = ' . (int)$groupId . ' AND image_path = ' . $this->sqlValue($imagePath),
1955 0
1956 );
1957 }
1958
1959 public function saveMediaImage(int $productId, int $groupId, int $mediaId, string $title = '', string $alt = '', int $isPrimary = 0, int $sorter = 100): ?array {
1960 $this->install();
1961 $productId = max(0, $productId);
1962 $groupId = max(0, $groupId);
1963 $mediaId = max(0, $mediaId);
1964 if ($mediaId <= 0 || ($productId <= 0 && $groupId <= 0)) {
1965 return null;
1966 }
1967 if ($groupId > 0 && $productId <= 0) {
1968 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), 'group_id = ' . (int)$groupId . ' AND product_id = 0 AND trash = 0', 0);
1969 $isPrimary = 1;
1970 }
1971
1972 $pathKey = 'dbxmedia:' . $mediaId;
1973 $this->db()->save(
1974 $this->dd('shopProductImage'),
1975 array(
1976 'product_id' => $productId,
1977 'group_id' => $groupId,
1978 'media_id' => $mediaId,
1979 'image_path' => $pathKey,
1980 'title' => $title,
1981 'alt' => $alt,
1982 'is_primary' => $isPrimary,
1983 'active' => 1,
1984 'sorter' => $sorter,
1985 ),
1986 'product_id = ' . (int)$productId . ' AND group_id = ' . (int)$groupId . ' AND image_path = ' . $this->sqlValue($pathKey),
1987 0
1988 );
1989
1990 $data = $this->db()->select1(
1991 $this->dd('shopProductImage'),
1992 'product_id = ' . (int)$productId . ' AND group_id = ' . (int)$groupId . ' AND image_path = ' . $this->sqlValue($pathKey),
1993 '*',
1994 0
1995 );
1996 return is_array($data) ? $data : null;
1997 }
1998
1999 public function primaryImageForGroup(int $groupId): ?array {
2000 $this->install();
2001 if ($groupId <= 0) {
2002 return null;
2003 }
2004 $rows = $this->db()->select(
2005 $this->dd('shopProductImage'),
2006 'trash = 0 AND active = 1 AND product_id = 0 AND group_id = ' . (int)$groupId,
2007 '*',
2008 'is_primary DESC, sorter ASC, title ASC',
2009 'ASC',
2010 '',
2011 0,
2012 1,
2013 0
2014 );
2015 $row = is_array($rows) && isset($rows[0]) ? $rows[0] : array();
2016 return is_array($row) ? $row : null;
2017 }
2018
2019 public function removeProductImageAssociation(int $imageId, int $productId = 0): bool {
2020 $this->install();
2021 $imageId = max(0, $imageId);
2022 $productId = max(0, $productId);
2023 if ($imageId <= 0) {
2024 return false;
2025 }
2026
2027 $where = 'id = ' . (int)$imageId;
2028 if ($productId > 0) {
2029 $groupMaps = $this->db()->select($this->dd('shopProductGroupMap'), 'product_id = ' . (int)$productId, 'group_id', '', 'ASC', '', 0, 0, 0);
2030 $groupIds = array();
2031 foreach ((is_array($groupMaps) ? $groupMaps : array()) as $groupMap) {
2032 $groupId = (int)($groupMap['group_id'] ?? 0);
2033 if ($groupId > 0) $groupIds[$groupId] = $groupId;
2034 }
2035 $parts = array('product_id = ' . (int)$productId);
2036 if ($groupIds !== array()) {
2037 $parts[] = 'group_id IN (' . implode(',', array_map('intval', $groupIds)) . ')';
2038 }
2039 $where .= ' AND (' . implode(' OR ', $parts) . ')';
2040 }
2041
2042 if ($this->db()->count($this->dd('shopProductImage'), $where) <= 0) {
2043 return false;
2044 }
2045 $this->db()->update($this->dd('shopProductImage'), array('active' => 0, 'trash' => 1), $where, 0);
2046 return true;
2047 }
2048
2049 public function createOrderFromItems(array $items, string $channelKey = 'shop', string $customerName = '', string $customerEmail = '', string $note = '', string $paymentProvider = '', string $paymentStatus = 'open', string $status = 'payment_pending', string $customerPhone = '', string $shippingAddress = '', string $legalSnapshot = '', string $withdrawalSnapshot = ''): ?array {
2050 $this->install();
2051 if ($items === array()) {
2052 return null;
2053 }
2054
2055 $now = date('Y-m-d H:i:s');
2056 $orderNo = 'S' . date('YmdHis') . '-' . random_int(1000, 9999);
2057 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
2058 $allowedStatus = array('new', 'payment_pending', 'paid', 'processing', 'shipped', 'done', 'cancelled');
2059 $allowedPayment = array('open', 'created', 'pending', 'completed', 'paid', 'failed', 'cancelled', 'refunded');
2060 if (!in_array($status, $allowedStatus, true)) {
2061 $status = 'payment_pending';
2062 }
2063 if (!in_array($paymentStatus, $allowedPayment, true)) {
2064 $paymentStatus = 'open';
2065 }
2066 $total = 0.0;
2067 $snapshots = array();
2068
2069 foreach ($items as $sku => $qty) {
2070 $qty = max(1, (int)$qty);
2071 $product = $this->productBySku((string)$sku);
2072 if (!$product) continue;
2073 $price = (float)($product['price_gross'] ?? 0);
2074 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
2075 $line = ($price + $shipping) * $qty;
2076 $total += $line;
2077 $snapshots[] = array(
2078 'product_id' => (int)($product['id'] ?? 0),
2079 'sku' => (string)($product['sku'] ?? $sku),
2080 'title' => (string)($product['title'] ?? ''),
2081 'qty' => $qty,
2082 'price_gross' => $price,
2083 'tax_rate' => (float)($product['effective_tax_rate'] ?? 0),
2084 'shipping_gross' => $shipping,
2085 'total_gross' => $line,
2086 );
2087 }
2088
2089 if ($snapshots === array()) {
2090 return null;
2091 }
2092 $stockIssues = $this->stockIssuesForItems($items);
2093 if ($stockIssues !== array()) {
2094 $first = $stockIssues[0];
2095 throw new \RuntimeException('Nicht genuegend Lagerbestand fuer ' . (string)($first['title'] ?? $first['sku'] ?? 'Artikel') . '.');
2096 }
2097 $stockReserved = $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2098
2099 $orderOk = (int)$this->db()->insert($this->dd('shopOrder'), array(
2100 'create_date' => $now,
2101 'update_date' => $now,
2102 'order_no' => $orderNo,
2103 'uid' => $uid,
2104 'status' => $status,
2105 'customer_name' => $customerName,
2106 'customer_email' => $customerEmail,
2107 'customer_phone' => $customerPhone,
2108 'shipping_address' => $shippingAddress,
2109 'total_gross' => $total,
2110 'currency' => 'EUR',
2111 'channel_key' => $channelKey,
2112 'payment_provider' => $paymentProvider,
2113 'payment_status' => $paymentStatus,
2114 'stock_reserved' => $stockReserved,
2115 'legal_snapshot' => $legalSnapshot,
2116 'withdrawal_snapshot' => $withdrawalSnapshot,
2117 'note' => $note,
2118 ), 0);
2119 if ($orderOk !== 1) {
2120 return null;
2121 }
2122 $orderId = (int)$this->db()->get_insert_id();
2123 if ($orderId <= 0) {
2124 return null;
2125 }
2126
2127 foreach ($snapshots as $item) {
2128 $this->db()->insert($this->dd('shopOrderItem'), array(
2129 'create_date' => $now,
2130 'update_date' => $now,
2131 'order_id' => $orderId,
2132 'product_id' => $item['product_id'],
2133 'sku' => $item['sku'],
2134 'title' => $item['title'],
2135 'qty' => $item['qty'],
2136 'price_gross' => $item['price_gross'],
2137 'tax_rate' => $item['tax_rate'],
2138 'shipping_gross' => $item['shipping_gross'],
2139 'total_gross' => $item['total_gross'],
2140 ), 0);
2141 }
2142 $reserved = $this->reserveStockForSnapshots($snapshots);
2143 if ($stockReserved === 1 && $reserved <= 0) {
2144 $this->db()->update($this->dd('shopOrder'), array('stock_reserved' => 0), 'id = ' . $orderId . ' AND trash = 0', 0);
2145 }
2146 $this->addOrderHistory($orderId, 'created', '', $status, 'Bestellung wurde angelegt.');
2147
2148 return $this->orderByNo($orderNo);
2149 }
2150
2151 public function importChannelOrder(string $channelKey, array $payload): ?array {
2152 $this->install();
2153 $channel = $this->channelByKey($channelKey);
2154 if (!$channel || (int)($channel['order_import_enabled'] ?? 0) !== 1 || (int)($channel['active'] ?? 0) !== 1) {
2155 throw new \RuntimeException('Order-Import fuer diesen Channel ist nicht aktiv.');
2156 }
2157
2158 $externalId = trim((string)($payload['order_id'] ?? $payload['external_order_id'] ?? $payload['id'] ?? ''));
2159 if ($externalId === '') {
2160 throw new \RuntimeException('Payload enthaelt keine externe Bestellnummer.');
2161 }
2162
2163 $paymentStatus = strtolower((string)($payload['payment_status'] ?? $payload['status'] ?? 'completed'));
2164 $normalizedPayment = in_array($paymentStatus, array('paid', 'completed', 'captured'), true)
2165 ? 'completed'
2166 : (in_array($paymentStatus, array('cancelled', 'canceled', 'voided'), true) ? 'cancelled' : 'pending');
2167
2168 $existing = $this->orderByPaymentReference($channelKey, $externalId);
2169 if ($existing) {
2170 $this->updateOrderPayment((int)$existing['id'], $channelKey, $normalizedPayment, $externalId, $payload);
2171 return $this->orderByNo((string)$existing['order_no']);
2172 }
2173
2174 $items = is_array($payload['items'] ?? null) ? $payload['items'] : array();
2175 if ($items === array()) {
2176 throw new \RuntimeException('Payload enthaelt keine Positionen.');
2177 }
2178
2179 $now = date('Y-m-d H:i:s');
2180 $orderNo = 'C' . date('YmdHis') . '-' . random_int(1000, 9999);
2181 $customer = is_array($payload['customer'] ?? null) ? $payload['customer'] : array();
2182 $customerName = (string)($payload['customer_name'] ?? $customer['name'] ?? '');
2183 $customerEmail = (string)($payload['customer_email'] ?? $customer['email'] ?? '');
2184 $customerPhone = (string)($payload['customer_phone'] ?? $customer['phone'] ?? '');
2185 $shipping = is_array($payload['shipping_address'] ?? null) ? $payload['shipping_address'] : (is_array($payload['shipping'] ?? null) ? $payload['shipping'] : array());
2186 $shippingAddress = trim((string)($payload['shipping_address_text'] ?? $payload['address'] ?? ''));
2187 if ($shippingAddress === '' && $shipping !== array()) {
2188 $shippingAddress = trim(implode("\n", array_filter(array_map('strval', array(
2189 $shipping['name'] ?? $customerName,
2190 $shipping['street'] ?? $shipping['address1'] ?? '',
2191 $shipping['address2'] ?? '',
2192 trim((string)($shipping['zip'] ?? $shipping['postal_code'] ?? '') . ' ' . (string)($shipping['city'] ?? '')),
2193 $shipping['country'] ?? $shipping['country_code'] ?? '',
2194 )))));
2195 }
2196 $currency = (string)($payload['currency'] ?? 'EUR');
2197 $snapshots = array();
2198 $total = 0.0;
2199
2200 foreach ($items as $item) {
2201 if (!is_array($item)) {
2202 continue;
2203 }
2204 $sku = (string)($item['sku'] ?? $item['seller_sku'] ?? $item['item_sku'] ?? '');
2205 $qty = max(1, (int)($item['qty'] ?? $item['quantity'] ?? 1));
2206 $product = $sku !== '' ? $this->productBySku($sku, false) : null;
2207 $price = (float)($item['price_gross'] ?? $item['price'] ?? $item['unit_price'] ?? ($product['price_gross'] ?? 0));
2208 $shipping = (float)($item['shipping_gross'] ?? $item['shipping'] ?? 0);
2209 $lineTotal = (float)($item['total_gross'] ?? $item['total'] ?? (($price + $shipping) * $qty));
2210 $total += $lineTotal;
2211 $snapshots[] = array(
2212 'product_id' => (int)($product['id'] ?? 0),
2213 'sku' => $sku,
2214 'title' => (string)($item['title'] ?? $item['name'] ?? $product['title'] ?? $sku),
2215 'qty' => $qty,
2216 'price_gross' => $price,
2217 'tax_rate' => (float)($item['tax_rate'] ?? $product['effective_tax_rate'] ?? 0),
2218 'shipping_gross' => $shipping,
2219 'total_gross' => $lineTotal,
2220 );
2221 }
2222
2223 if ($snapshots === array()) {
2224 throw new \RuntimeException('Payload enthaelt keine verwertbaren Positionen.');
2225 }
2226 if (isset($payload['total_gross']) || isset($payload['total']) || isset($payload['amount'])) {
2227 $total = (float)($payload['total_gross'] ?? $payload['total'] ?? $payload['amount']);
2228 }
2229 $stockReserved = $this->hasReservableStockSnapshots($snapshots) ? 1 : 0;
2230
2231 $orderOk = (int)$this->db()->insert($this->dd('shopOrder'), array(
2232 'create_date' => $now,
2233 'update_date' => $now,
2234 'order_no' => $orderNo,
2235 'uid' => 0,
2236 'status' => $normalizedPayment === 'completed' ? 'paid' : ($normalizedPayment === 'cancelled' ? 'cancelled' : 'payment_pending'),
2237 'customer_name' => $customerName,
2238 'customer_email' => $customerEmail,
2239 'customer_phone' => $customerPhone,
2240 'shipping_address' => $shippingAddress,
2241 'total_gross' => $total,
2242 'currency' => $currency,
2243 'channel_key' => $channelKey,
2244 'payment_provider' => $channelKey,
2245 'payment_status' => $normalizedPayment,
2246 'stock_reserved' => $stockReserved,
2247 'payment_reference' => $externalId,
2248 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
2249 ), 0);
2250 if ($orderOk !== 1) {
2251 return null;
2252 }
2253 $orderId = (int)$this->db()->get_insert_id();
2254 if ($orderId <= 0) {
2255 return null;
2256 }
2257
2258 foreach ($snapshots as $item) {
2259 $this->db()->insert($this->dd('shopOrderItem'), array(
2260 'create_date' => $now,
2261 'update_date' => $now,
2262 'order_id' => $orderId,
2263 'product_id' => $item['product_id'],
2264 'sku' => $item['sku'],
2265 'title' => $item['title'],
2266 'qty' => $item['qty'],
2267 'price_gross' => $item['price_gross'],
2268 'tax_rate' => $item['tax_rate'],
2269 'shipping_gross' => $item['shipping_gross'],
2270 'total_gross' => $item['total_gross'],
2271 ), 0);
2272 }
2273 $reserved = $this->reserveStockForSnapshots($snapshots);
2274 if ($stockReserved === 1 && $reserved <= 0) {
2275 $this->db()->update($this->dd('shopOrder'), array('stock_reserved' => 0), 'id = ' . $orderId . ' AND trash = 0', 0);
2276 }
2277 $this->addOrderHistory($orderId, 'channel_import', '', $normalizedPayment, 'Bestellung wurde ueber Channel ' . $channelKey . ' importiert.');
2278
2279 return $this->orderByNo($orderNo);
2280 }
2281
2282 public function orderByNo(string $orderNo): ?array {
2283 $this->install();
2284 $row = $this->db()->select1($this->dd('shopOrder'), 'order_no = ' . $this->sqlValue($orderNo) . ' AND trash = 0', '*', 0);
2285 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) return null;
2286 $row['items'] = $this->orderItems((int)$row['id']);
2287 $row['history'] = $this->orderHistory((int)$row['id']);
2288 $row['withdrawals'] = $this->withdrawalsForOrder((int)$row['id']);
2289 return $row;
2290 }
2291
2292 public function orderById(int $id): ?array {
2293 $this->install();
2294 $row = $this->db()->select1($this->dd('shopOrder'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
2295 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) return null;
2296 $row['items'] = $this->orderItems((int)$row['id']);
2297 $row['history'] = $this->orderHistory((int)$row['id']);
2298 $row['withdrawals'] = $this->withdrawalsForOrder((int)$row['id']);
2299 return $row;
2300 }
2301
2302 public function ordersByUid(int $uid, int $limit = 25): array {
2303 $this->install();
2304 if ($uid <= 0) {
2305 return array();
2306 }
2307 $rows = $this->db()->select($this->dd('shopOrder'), 'uid = ' . (int)$uid . ' AND trash = 0', '*', 'create_date DESC, id DESC', 'ASC', '', max(1, min(100, $limit)), 0, 0);
2308 $rows = is_array($rows) ? $rows : array();
2309 foreach ($rows as &$row) {
2310 $orderId = (int)($row['id'] ?? 0);
2311 $row['items'] = $this->orderItems($orderId);
2312 $row['history'] = $this->orderHistory($orderId);
2313 $row['withdrawals'] = $this->withdrawalsForOrder($orderId);
2314 }
2315 unset($row);
2316 return $rows;
2317 }
2318
2319 public function orders(array $filters = array(), int $limit = 50, int $offset = 0, string $sort = 'create_date', string $direction = 'DESC'): array {
2320 $this->install();
2321 $where = array('trash = 0');
2322
2323 $query = trim((string)($filters['query'] ?? ''));
2324 if ($query !== '') {
2325 $like = $this->sqlLikeValue($query);
2326 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ' OR channel_key LIKE ' . $like . ' OR payment_reference LIKE ' . $like . ')';
2327 }
2328 foreach (array('status', 'payment_status', 'shipping_status', 'channel_key') as $field) {
2329 $value = trim((string)($filters[$field] ?? ''));
2330 if ($value !== '') {
2331 $where[] = $field . ' = ' . $this->sqlValue($value);
2332 }
2333 }
2334
2335 $allowedSort = array('create_date', 'order_no', 'status', 'payment_status', 'shipping_status', 'customer_name', 'total_gross', 'channel_key');
2336 if (!in_array($sort, $allowedSort, true)) {
2337 $sort = 'create_date';
2338 }
2339 $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
2340 $max = $limit > 0 ? max(1, $limit) : 0;
2341 $rows = $this->db()->select($this->dd('shopOrder'), implode(' AND ', $where), '*', $sort . ' ' . $direction . ', id DESC', 'ASC', '', $max, max(0, $offset), 0);
2342 $rows = is_array($rows) ? $rows : array();
2343 foreach ($rows as &$row) {
2344 $row['items'] = $this->orderItems((int)$row['id']);
2345 }
2346 unset($row);
2347 return $rows;
2348 }
2349
2350 public function orderCount(array $filters = array()): int {
2351 $this->install();
2352 $where = array('trash = 0');
2353 $query = trim((string)($filters['query'] ?? ''));
2354 if ($query !== '') {
2355 $like = $this->sqlLikeValue($query);
2356 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ' OR channel_key LIKE ' . $like . ' OR payment_reference LIKE ' . $like . ')';
2357 }
2358 foreach (array('status', 'payment_status', 'shipping_status', 'channel_key') as $field) {
2359 $value = trim((string)($filters[$field] ?? ''));
2360 if ($value !== '') {
2361 $where[] = $field . ' = ' . $this->sqlValue($value);
2362 }
2363 }
2364 return max(0, (int)$this->db()->count($this->dd('shopOrder'), implode(' AND ', $where)));
2365 }
2366
2367 public function orderChannelKeys(): array {
2368 $this->install();
2369 $rows = $this->db()->select($this->dd('shopOrder'), "trash = 0 AND channel_key <> ''", 'channel_key', 'channel_key ASC', 'ASC', '', 0, 0, 0);
2370 $keys = array();
2371 foreach (is_array($rows) ? $rows : array() as $row) {
2372 $key = (string)($row['channel_key'] ?? '');
2373 if ($key !== '') {
2374 $keys[$key] = $key;
2375 }
2376 }
2377 ksort($keys);
2378 return array_values($keys);
2379 }
2380
2381 public function updateOrderAdmin(int $id, array $data): bool {
2382 $this->install();
2383 $allowedStatus = array('new', 'payment_pending', 'paid', 'processing', 'shipped', 'done', 'cancelled');
2384 $allowedPayment = array('open', 'created', 'pending', 'completed', 'paid', 'failed', 'cancelled', 'refunded');
2385 $allowedShipping = array('open', 'ready', 'shipped', 'delivered', 'returned');
2386 $before = $this->orderById($id);
2387 $status = (string)($data['status'] ?? 'new');
2388 $paymentStatus = (string)($data['payment_status'] ?? 'open');
2389 $shippingStatus = (string)($data['shipping_status'] ?? ($before['shipping_status'] ?? 'open'));
2390 if (!in_array($status, $allowedStatus, true)) $status = 'new';
2391 if (!in_array($paymentStatus, $allowedPayment, true)) $paymentStatus = 'open';
2392 if (!in_array($shippingStatus, $allowedShipping, true)) $shippingStatus = 'open';
2393 $invoiceNo = trim((string)($data['invoice_no'] ?? ($before['invoice_no'] ?? '')));
2394 $invoiceDate = trim((string)($data['invoice_date'] ?? ($before['invoice_date'] ?? '')));
2395 if ($invoiceNo === '' && in_array($status, array('paid', 'processing', 'shipped', 'done'), true)) {
2396 $invoiceNo = $this->nextInvoiceNo();
2397 $invoiceDate = date('Y-m-d');
2398 }
2399 $shippedDate = trim((string)($data['shipped_date'] ?? ($before['shipped_date'] ?? '')));
2400 if ($shippingStatus === 'shipped' && $shippedDate === '') {
2401 $shippedDate = date('Y-m-d H:i:s');
2402 }
2403 $shippingProvider = trim((string)($data['shipping_provider'] ?? ''));
2404 $trackingNo = trim((string)($data['tracking_no'] ?? ''));
2405 $trackingUrl = trim((string)($data['tracking_url'] ?? ''));
2406 if ($trackingUrl === '' && $trackingNo !== '') {
2407 $trackingUrl = $this->trackingUrlForProvider($shippingProvider, $trackingNo);
2408 }
2409
2410 $ok = $this->db()->update($this->dd('shopOrder'), array(
2411 'update_date' => date('Y-m-d H:i:s'),
2412 'status' => $status,
2413 'payment_status' => $paymentStatus,
2414 'payment_reference' => (string)($data['payment_reference'] ?? ''),
2415 'invoice_no' => $invoiceNo,
2416 'invoice_date' => $invoiceDate,
2417 'shipping_status' => $shippingStatus,
2418 'shipping_provider' => $shippingProvider,
2419 'tracking_no' => $trackingNo,
2420 'tracking_url' => $trackingUrl,
2421 'shipped_date' => $shippedDate,
2422 'note' => (string)($data['note'] ?? ''),
2423 ), 'id = ' . (int)$id . ' AND trash = 0', 0);
2424 if (is_array($before)) {
2425 foreach (array(
2426 'status' => $status,
2427 'payment_status' => $paymentStatus,
2428 'shipping_status' => $shippingStatus,
2429 'invoice_no' => $invoiceNo,
2430 'tracking_no' => $trackingNo,
2431 ) as $field => $newValue) {
2432 $oldValue = (string)($before[$field] ?? '');
2433 if ($oldValue !== (string)$newValue) {
2434 $this->addOrderHistory($id, $field, $oldValue, (string)$newValue, 'Admin-Aenderung');
2435 }
2436 }
2437 if ($status === 'cancelled' || in_array($paymentStatus, array('cancelled', 'refunded'), true) || $shippingStatus === 'returned') {
2438 $fresh = $this->orderById($id);
2439 if (is_array($fresh)) {
2440 $this->releaseStockForOrder($fresh, 'Bestand wurde durch Statusaenderung zurueckgebucht.');
2441 }
2442 }
2443 }
2444 return $ok !== 0 || $this->orderById($id) !== null;
2445 }
2446
2447 private function trackingUrlForProvider(string $provider, string $trackingNo): string {
2448 $trackingNo = trim($trackingNo);
2449 if ($trackingNo === '') {
2450 return '';
2451 }
2452 $providerKey = strtolower(trim($provider));
2453 if (str_contains($providerKey, 'dhl')) {
2454 return 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=' . rawurlencode($trackingNo);
2455 }
2456 if (str_contains($providerKey, 'ups')) {
2457 return 'https://www.ups.com/track?tracknum=' . rawurlencode($trackingNo);
2458 }
2459 if (str_contains($providerKey, 'dpd')) {
2460 return 'https://tracking.dpd.de/status/de_DE/parcel/' . rawurlencode($trackingNo);
2461 }
2462 if (str_contains($providerKey, 'hermes')) {
2463 return 'https://www.myhermes.de/empfangen/sendungsverfolgung/?su=' . rawurlencode($trackingNo);
2464 }
2465 return '';
2466 }
2467
2468 public function updateOrderQuickAction(int $id, string $action): array {
2469 $this->install();
2470 $order = $this->orderById($id);
2471 if (!is_array($order)) {
2472 return array(false, 'Bestellung nicht gefunden.');
2473 }
2474
2475 $data = $order;
2476 $message = '';
2477 switch ($action) {
2478 case 'mark_paid':
2479 $data['payment_status'] = 'paid';
2480 if (in_array((string)($data['status'] ?? ''), array('new', 'payment_pending'), true)) {
2481 $data['status'] = 'paid';
2482 }
2483 $message = 'Bestellung wurde als bezahlt markiert.';
2484 break;
2485
2486 case 'processing':
2487 $data['status'] = 'processing';
2488 $message = 'Bestellung wurde in Bearbeitung gesetzt.';
2489 break;
2490
2491 case 'ready':
2492 $data['shipping_status'] = 'ready';
2493 if (in_array((string)($data['status'] ?? ''), array('new', 'payment_pending', 'paid'), true)) {
2494 $data['status'] = 'processing';
2495 }
2496 $message = 'Bestellung wurde als versandbereit markiert.';
2497 break;
2498
2499 case 'shipped':
2500 $data['shipping_status'] = 'shipped';
2501 $data['status'] = 'shipped';
2502 if (trim((string)($data['shipped_date'] ?? '')) === '') {
2503 $data['shipped_date'] = date('Y-m-d H:i:s');
2504 }
2505 $message = 'Bestellung wurde als versendet markiert.';
2506 break;
2507
2508 case 'delivered':
2509 $data['shipping_status'] = 'delivered';
2510 $data['status'] = 'done';
2511 $message = 'Bestellung wurde als zugestellt und abgeschlossen markiert.';
2512 break;
2513
2514 case 'cancel':
2515 $data['status'] = 'cancelled';
2516 if (!in_array((string)($data['payment_status'] ?? ''), array('completed', 'paid', 'refunded'), true)) {
2517 $data['payment_status'] = 'cancelled';
2518 }
2519 $message = 'Bestellung wurde storniert.';
2520 break;
2521
2522 case 'refund':
2523 $data['payment_status'] = 'refunded';
2524 $data['status'] = 'cancelled';
2525 if ((string)($data['shipping_status'] ?? '') !== 'delivered') {
2526 $data['shipping_status'] = 'returned';
2527 }
2528 $message = 'Bestellung wurde als erstattet markiert.';
2529 break;
2530
2531 default:
2532 return array(false, 'Unbekannte Bestellaktion.');
2533 }
2534
2535 $ok = $this->updateOrderAdmin($id, $data);
2536 if ($ok) {
2537 $this->addOrderHistory($id, 'quick_action', '', $action, $message);
2538 return array(true, $message);
2539 }
2540 return array(false, 'Bestellaktion konnte nicht gespeichert werden.');
2541 }
2542
2543 public function deleteOrder(int $id): bool {
2544 $this->install();
2545 return $this->db()->update($this->dd('shopOrder'), array('trash' => 1, 'update_date' => date('Y-m-d H:i:s')), 'id = ' . (int)$id . ' AND trash = 0', 0) !== 0;
2546 }
2547
2548 public function orderByPaymentReference(string $provider, string $reference): ?array {
2549 $this->install();
2550 $row = $this->db()->select1($this->dd('shopOrder'), 'payment_provider = ' . $this->sqlValue($provider) . ' AND payment_reference = ' . $this->sqlValue($reference) . ' AND trash = 0', '*', 0);
2551 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) return null;
2552 $row['items'] = $this->orderItems((int)$row['id']);
2553 return $row;
2554 }
2555
2556 public function orderItems(int $orderId): array {
2557 $rows = $this->db()->select($this->dd('shopOrderItem'), 'order_id = ' . (int)$orderId . ' AND trash = 0', '*', 'id ASC', 'ASC', '', 0, 0, 0);
2558 return is_array($rows) ? $rows : array();
2559 }
2560
2561 public function updateOrderPayment(int $orderId, string $provider, string $status, string $reference = '', array $payload = array()): void {
2562 $this->install();
2563 $orderStatus = $status === 'completed' ? 'paid' : ($status === 'cancelled' ? 'cancelled' : 'payment_pending');
2564 $this->db()->update($this->dd('shopOrder'), array(
2565 'update_date' => date('Y-m-d H:i:s'),
2566 'payment_provider' => $provider,
2567 'payment_status' => $status,
2568 'payment_reference' => $reference,
2569 'payment_payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
2570 'status' => $orderStatus,
2571 ), 'id = ' . (int)$orderId, 0);
2572 $this->addOrderHistory($orderId, 'payment', '', $status, 'Zahlungsstatus wurde aktualisiert.');
2573 if (in_array($status, array('cancelled', 'refunded'), true) || $orderStatus === 'cancelled') {
2574 $fresh = $this->orderById($orderId);
2575 if (is_array($fresh)) {
2576 $this->releaseStockForOrder($fresh, 'Bestand wurde durch Zahlungsstatus zurueckgebucht.');
2577 }
2578 }
2579 }
2580
2581 public function addOrderHistory(int $orderId, string $eventType, string $oldValue = '', string $newValue = '', string $message = ''): void {
2582 if ($orderId <= 0) {
2583 return;
2584 }
2585 $order = $this->db()->select1($this->dd('shopOrder'), 'id = ' . (int)$orderId . ' AND trash = 0', 'owner,uid', 0);
2586 $owner = is_array($order) ? (int)($order['owner'] ?? 0) : 0;
2587 if ($owner <= 0 && is_array($order)) {
2588 $owner = (int)($order['uid'] ?? 0);
2589 }
2590 $data = array(
2591 'order_id' => $orderId,
2592 'event_type' => $eventType,
2593 'old_value' => $oldValue,
2594 'new_value' => $newValue,
2595 'message' => $message,
2596 );
2597 if ($owner > 0) {
2598 $data['owner'] = $owner;
2599 }
2600 $this->db()->insert($this->dd('shopOrderHistory'), $data, 0);
2601 }
2602
2603 public function orderHistory(int $orderId): array {
2604 $rows = $this->db()->select($this->dd('shopOrderHistory'), 'order_id = ' . (int)$orderId . ' AND trash = 0', '*', 'create_date DESC, id DESC', 'ASC', '', 0, 0, 0);
2605 return is_array($rows) ? $rows : array();
2606 }
2607
2608 public function nextInvoiceNo(): string {
2609 $prefix = 'R' . date('Y');
2610 $rows = $this->db()->select($this->dd('shopOrder'), 'invoice_no LIKE ' . $this->sqlValue($prefix . '%'), 'invoice_no', 'invoice_no DESC', 'DESC', '', 1, 0, 0);
2611 $last = is_array($rows) && isset($rows[0]['invoice_no']) ? (string)$rows[0]['invoice_no'] : '';
2612 $next = 1;
2613 if (preg_match('/(\d+)$/', $last, $m)) {
2614 $next = ((int)$m[1]) + 1;
2615 }
2616 return $prefix . '-' . str_pad((string)$next, 5, '0', STR_PAD_LEFT);
2617 }
2618
2619 private function invoiceArchiveDir(string $year): string {
2620 $base = function_exists('dbx_get_base_dir') ? rtrim((string)dbx_get_base_dir(), '/\\') : dirname(__DIR__, 4);
2621 return $base . '/files/shop/invoices/' . $year;
2622 }
2623
2624 private function invoiceArchiveRelPath(string $year, string $fileName): string {
2625 return 'files/shop/invoices/' . $year . '/' . $fileName;
2626 }
2627
2628 private function pdfText(string $text): string {
2629 $text = str_replace(array("\r\n", "\r"), "\n", $text);
2630 $text = strtr($text, array(
2631 '€' => 'EUR',
2632 '„' => '"',
2633 '“' => '"',
2634 '’' => "'",
2635 '–' => '-',
2636 '—' => '-',
2637 ));
2638 $converted = function_exists('iconv') ? @iconv('UTF-8', 'Windows-1252//TRANSLIT', $text) : false;
2639 if ($converted !== false) {
2640 $text = $converted;
2641 }
2642 return str_replace(array('\\', '(', ')'), array('\\\\', '\\(', '\\)'), $text);
2643 }
2644
2645 private function createSimpleInvoicePdf(array $order, string $absFile): bool {
2646 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
2647 $invoiceDate = trim((string)($order['invoice_date'] ?? date('Y-m-d')));
2648 $lines = array(
2649 'Rechnung ' . $invoiceNo,
2650 'Datum: ' . $invoiceDate,
2651 'Bestellung: ' . (string)($order['order_no'] ?? ''),
2652 '',
2653 'Kunde:',
2654 (string)($order['customer_name'] ?? ''),
2655 (string)($order['customer_email'] ?? ''),
2656 );
2657 $address = trim((string)($order['shipping_address'] ?? ''));
2658 if ($address !== '') {
2659 $lines[] = '';
2660 $lines[] = 'Lieferadresse:';
2661 foreach (explode("\n", str_replace("\r", '', $address)) as $line) {
2662 $lines[] = $line;
2663 }
2664 }
2665 $lines[] = '';
2666 $lines[] = 'Positionen:';
2667 foreach ((array)($order['items'] ?? array()) as $item) {
2668 $title = trim((string)($item['title'] ?? 'Artikel'));
2669 $sku = trim((string)($item['sku'] ?? ''));
2670 $qty = (int)($item['qty'] ?? 0);
2671 $total = number_format((float)($item['total_gross'] ?? 0), 2, ',', '.') . ' EUR';
2672 $tax = number_format((float)($item['tax_rate'] ?? 0), 2, ',', '.') . ' % MwSt.';
2673 $lines[] = $qty . ' x ' . $title . ($sku !== '' ? ' [' . $sku . ']' : '') . ' - ' . $total . ' (' . $tax . ')';
2674 }
2675 $lines[] = '';
2676 $lines[] = 'Gesamtbetrag: ' . number_format((float)($order['total_gross'] ?? 0), 2, ',', '.') . ' EUR';
2677 $lines[] = '';
2678 $lines[] = 'Dieser Beleg wurde aus dem gespeicherten Bestell-Snapshot erzeugt.';
2679
2680 $content = "BT\n/F1 18 Tf\n50 790 Td\n(" . $this->pdfText(array_shift($lines) ?: 'Rechnung') . ") Tj\n/F1 10 Tf\n0 -24 Td\n";
2681 foreach ($lines as $line) {
2682 foreach (explode("\n", wordwrap((string)$line, 95, "\n", true)) as $wrapped) {
2683 $content .= '(' . $this->pdfText($wrapped) . ") Tj\n0 -14 Td\n";
2684 }
2685 }
2686 $content .= "ET\n";
2687
2688 $objects = array();
2689 $objects[] = "<< /Type /Catalog /Pages 2 0 R >>";
2690 $objects[] = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
2691 $objects[] = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>";
2692 $objects[] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
2693 $objects[] = "<< /Length " . strlen($content) . " >>\nstream\n" . $content . "endstream";
2694
2695 $pdf = "%PDF-1.4\n";
2696 $offsets = array(0);
2697 foreach ($objects as $i => $object) {
2698 $offsets[$i + 1] = strlen($pdf);
2699 $pdf .= ($i + 1) . " 0 obj\n" . $object . "\nendobj\n";
2700 }
2701 $xref = strlen($pdf);
2702 $pdf .= "xref\n0 " . (count($objects) + 1) . "\n";
2703 $pdf .= "0000000000 65535 f \n";
2704 for ($i = 1; $i <= count($objects); $i++) {
2705 $pdf .= str_pad((string)$offsets[$i], 10, '0', STR_PAD_LEFT) . " 00000 n \n";
2706 }
2707 $pdf .= "trailer\n<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\nstartxref\n" . $xref . "\n%%EOF";
2708
2709 $dir = dirname($absFile);
2710 if (!is_dir($dir) && !@mkdir($dir, 0775, true)) {
2711 return false;
2712 }
2713 return @file_put_contents($absFile, $pdf) !== false;
2714 }
2715
2716 public function ensureOrderInvoicePdf(int $id): ?array {
2717 $this->install();
2718 $order = $this->orderById($id);
2719 if (!is_array($order)) {
2720 return null;
2721 }
2722
2723 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
2724 $invoiceDate = trim((string)($order['invoice_date'] ?? ''));
2725 $updates = array();
2726 if ($invoiceNo === '') {
2727 $invoiceNo = $this->nextInvoiceNo();
2728 $updates['invoice_no'] = $invoiceNo;
2729 }
2730 if ($invoiceDate === '') {
2731 $invoiceDate = date('Y-m-d');
2732 $updates['invoice_date'] = $invoiceDate;
2733 }
2734 if ($updates !== array()) {
2735 $updates['update_date'] = date('Y-m-d H:i:s');
2736 $this->db()->update($this->dd('shopOrder'), $updates, 'id = ' . (int)$id . ' AND trash = 0', 0);
2737 $order = $this->orderById($id) ?: $order;
2738 }
2739
2740 $year = substr($invoiceDate, 0, 4);
2741 if (!preg_match('/^\d{4}$/', $year)) {
2742 $year = date('Y');
2743 }
2744 $safeNo = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $invoiceNo) ?: ('rechnung-' . $id);
2745 $fileName = $safeNo . '.pdf';
2746 $relPath = $this->invoiceArchiveRelPath($year, $fileName);
2747 $absPath = $this->invoiceArchiveDir($year) . '/' . $fileName;
2748 $oldRelPath = trim((string)($order['invoice_pdf_path'] ?? ''));
2749 $created = false;
2750
2751 if (!is_file($absPath)) {
2752 if (!$this->createSimpleInvoicePdf($order, $absPath)) {
2753 return null;
2754 }
2755 $created = true;
2756 }
2757
2758 $this->db()->update($this->dd('shopOrder'), array(
2759 'invoice_pdf_path' => $relPath,
2760 'invoice_pdf_date' => date('Y-m-d H:i:s'),
2761 'update_date' => date('Y-m-d H:i:s'),
2762 ), 'id = ' . (int)$id . ' AND trash = 0', 0);
2763 if ($created || $oldRelPath !== $relPath) {
2764 $this->addOrderHistory($id, 'invoice_pdf', '', $relPath, 'Rechnungs-PDF wurde erzeugt oder aktualisiert.');
2765 }
2766 return $this->orderById($id);
2767 }
2768
2769 public function invoicePdfAbsolutePath(array $order): string {
2770 $rel = trim((string)($order['invoice_pdf_path'] ?? ''));
2771 if ($rel === '') {
2772 return '';
2773 }
2774 $base = function_exists('dbx_get_base_dir') ? rtrim((string)dbx_get_base_dir(), '/\\') : dirname(__DIR__, 4);
2775 $path = $base . '/' . ltrim($rel, '/\\');
2776 return is_file($path) ? $path : '';
2777 }
2778
2779 public function saveWithdrawal(array $data): ?array {
2780 $this->install();
2781 $orderNo = trim((string)($data['order_no'] ?? ''));
2782 $order = $orderNo !== '' ? $this->orderByNo($orderNo) : null;
2783 $orderId = is_array($order) ? (int)($order['id'] ?? 0) : 0;
2784 $ok = (int)$this->db()->insert($this->dd('shopWithdrawal'), array(
2785 'order_id' => $orderId,
2786 'order_no' => $orderNo,
2787 'customer_name' => trim((string)($data['customer_name'] ?? '')),
2788 'customer_email' => trim((string)($data['customer_email'] ?? '')),
2789 'customer_address' => trim((string)($data['customer_address'] ?? '')),
2790 'reason' => trim((string)($data['reason'] ?? '')),
2791 'status' => 'new',
2792 ), 0);
2793 if ($ok !== 1) {
2794 return null;
2795 }
2796 $id = (int)$this->db()->get_insert_id();
2797 if ($orderId > 0) {
2798 $this->addOrderHistory($orderId, 'withdrawal', '', 'new', 'Widerruf wurde eingereicht.');
2799 }
2800 return $this->withdrawalById($id);
2801 }
2802
2803 public function updateWithdrawalAdmin(int $id, string $status, string $note = ''): bool {
2804 $this->install();
2805 $allowed = array('new', 'processing', 'accepted', 'rejected', 'refunded', 'closed');
2806 if (!in_array($status, $allowed, true)) {
2807 return false;
2808 }
2809 $before = $this->withdrawalById($id);
2810 if (!is_array($before)) {
2811 return false;
2812 }
2813
2814 $ok = $this->db()->update($this->dd('shopWithdrawal'), array(
2815 'status' => $status,
2816 'admin_note' => $note !== '' ? $note : (string)($before['admin_note'] ?? ''),
2817 'update_date' => date('Y-m-d H:i:s'),
2818 ), 'id = ' . (int)$id . ' AND trash = 0', 0);
2819
2820 $orderId = (int)($before['order_id'] ?? 0);
2821 if ($orderId > 0) {
2822 $old = (string)($before['status'] ?? '');
2823 if ($old !== $status) {
2824 $this->addOrderHistory($orderId, 'withdrawal_status', $old, $status, 'Widerrufsstatus wurde geaendert.');
2825 }
2826 if (in_array($status, array('accepted', 'refunded'), true)) {
2827 $order = $this->orderById($orderId);
2828 if (is_array($order)) {
2829 $this->releaseStockForOrder($order, 'Bestand wurde durch Widerruf zurueckgebucht.');
2830 }
2831 }
2832 }
2833
2834 return $ok !== 0 || $this->withdrawalById($id) !== null;
2835 }
2836
2837 public function withdrawalById(int $id): ?array {
2838 $row = $this->db()->select1($this->dd('shopWithdrawal'), 'id = ' . (int)$id . ' AND trash = 0', '*', 0);
2839 return is_array($row) ? $row : null;
2840 }
2841
2842 public function withdrawals(array $filters = array(), int $limit = 50, int $offset = 0): array {
2843 $this->install();
2844 $where = array('trash = 0');
2845 $query = trim((string)($filters['query'] ?? ''));
2846 if ($query !== '') {
2847 $like = $this->sqlLikeValue($query);
2848 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ')';
2849 }
2850 $status = trim((string)($filters['status'] ?? ''));
2851 if ($status !== '') {
2852 $where[] = 'status = ' . $this->sqlValue($status);
2853 }
2854 $rows = $this->db()->select($this->dd('shopWithdrawal'), implode(' AND ', $where), '*', 'create_date DESC, id DESC', 'ASC', '', $limit > 0 ? $limit : 0, max(0, $offset), 0);
2855 return is_array($rows) ? $rows : array();
2856 }
2857
2858 public function withdrawalCount(array $filters = array()): int {
2859 $where = array('trash = 0');
2860 $query = trim((string)($filters['query'] ?? ''));
2861 if ($query !== '') {
2862 $like = $this->sqlLikeValue($query);
2863 $where[] = '(order_no LIKE ' . $like . ' OR customer_name LIKE ' . $like . ' OR customer_email LIKE ' . $like . ')';
2864 }
2865 $status = trim((string)($filters['status'] ?? ''));
2866 if ($status !== '') {
2867 $where[] = 'status = ' . $this->sqlValue($status);
2868 }
2869 return max(0, (int)$this->db()->count($this->dd('shopWithdrawal'), implode(' AND ', $where)));
2870 }
2871
2872 public function withdrawalsForOrder(int $orderId): array {
2873 if ($orderId <= 0) {
2874 return array();
2875 }
2876 $rows = $this->db()->select($this->dd('shopWithdrawal'), 'order_id = ' . (int)$orderId . ' AND trash = 0', '*', 'create_date DESC, id DESC', 'ASC', '', 0, 0, 0);
2877 return is_array($rows) ? $rows : array();
2878 }
2879
2880 public function dashboardStats(): array {
2881 $this->install();
2882 $ordersOpen = (int)$this->db()->count($this->dd('shopOrder'), "trash = 0 AND status IN ('new','payment_pending','paid','processing')");
2883 $paymentsOpen = (int)$this->db()->count($this->dd('shopOrder'), "trash = 0 AND payment_status IN ('open','created','pending')");
2884 $shipOpen = (int)$this->db()->count($this->dd('shopOrder'), "trash = 0 AND shipping_status IN ('open','ready')");
2885 $withdrawalsOpen = (int)$this->db()->count($this->dd('shopWithdrawal'), "trash = 0 AND status IN ('new','processing')");
2886 $stockLow = 0;
2887 if ($this->stockEnabled()) {
2888 $stockLow = (int)$this->db()->count($this->dd('shopProduct'), "trash = 0 AND active = 1 AND product_type = 'physical' AND stock <= 3");
2889 }
2890 return array(
2891 'orders_open' => $ordersOpen,
2892 'payments_open' => $paymentsOpen,
2893 'shipping_open' => $shipOpen,
2894 'withdrawals_open' => $withdrawalsOpen,
2895 'stock_low' => $stockLow,
2896 'products_active' => (int)$this->db()->count($this->dd('shopProduct'), 'trash = 0 AND active = 1'),
2897 );
2898 }
2899}
2900?>
$table['server']
Definition .dd.php:6
$cfg
importChannelOrder(string $channelKey, array $payload)
orderByPaymentReference(string $provider, string $reference)
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)
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='')
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())
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)
$field
Definition config.dd.php:4
if( $syncRequest)
Definition index.php:520
dbx_get_base_dir($cut_Data=0)
Definition index.php:157
DBX schema administration.
if(! $db->connect_db_server($server)) $result
foreach( $topics as $file=> $html)