diff --git a/.cursorrules b/.cursorrules index 172788d..85bb12d 100644 --- a/.cursorrules +++ b/.cursorrules @@ -17,6 +17,7 @@ Zanim napiszesz JAKIEKOLWIEK zapytanie SQL, sprawd┼║ plik ze struktur─ů bazy! ZA - **Macierz Cenowa:** NIGDY nie szukaj ceny w `sh_menu_items`. U┼╝ywaj `sh_price_tiers` (`target_type`, `target_sku`, `channel`). - **Modyfikatory:** `sh_modifiers` ┼é─ůczy si─Ö z magazynem przez `linked_warehouse_sku` i `linked_quantity`. - **Magazyn:** `sys_items` (s┼éownik, klucz `sku`) oraz `wh_stock` (stany rzeczywiste). +- **Silosy prefiksowe:** baza jest podzielona na trzy izolowane silosy (`sh_` biznes, `sys_` s┼éownik surowc├│w, `wh_` magazyn). Po┼é─ůczenia MI─śDZY silosami TYLKO przez klucze znakowe ÔÇö patrz ┬ž9. ## 2. ┼╗ELAZNA ZASADA IZOLACJI (Multi-Tenancy) - System jest architektur─ů wielonajemcow─ů. @@ -28,7 +29,7 @@ Zanim napiszesz JAKIEKOLWIEK zapytanie SQL, sprawd┼║ plik ze struktur─ů bazy! ZA - **ABSOLUTNY ZAKAZ:** Node.js, npm, Webpack, React, Vue, Angular, jQuery. ## 4. ARCHITEKTURA I MODU┼üOWO┼Ü─ć ("Klocki Lego") -- **Zasada 1=1:** Jeden modu┼é biznesowy = Jeden plik API (np. `api_warehouse.php`, `api_menu_studio.php`). +- **Zasada 1=1:** Jeden modu┼é biznesowy = sp├│jna warstwa API (np. `api/warehouse/*.php`, `api/backoffice/api_menu_studio.php`). - Routing na backendzie obs┼éugiwany wy┼é─ůcznie przez instrukcj─Ö `switch($action)`. ## 5. STANDARD KOMUNIKACJI API @@ -40,4 +41,77 @@ Zanim napiszesz JAKIEKOLWIEK zapytanie SQL, sprawd┼║ plik ze struktur─ů bazy! ZA - **Alert 86:** Wszelkie stany r├│wne 0 lub ujemne wyr├│┼╝niaj kolorem (`text-red-500 font-bold`). ## 7. ZASADA SNAJPERA (Edycja Kodu) -- Kiedy jeste┼Ť proszony o popraw─Ö b┼é─Ödu, dzia┼éaj PUNKTOWO. Nie refaktoryzuj ca┼éych plik├│w, nie pisz nowych test├│w, chyba ┼╝e u┼╝ytkownik wyda┼é na to rozkaz. \ No newline at end of file +- Kiedy jeste┼Ť proszony o popraw─Ö b┼é─Ödu, dzia┼éaj PUNKTOWO. Nie refaktoryzuj ca┼éych plik├│w, nie pisz nowych test├│w, chyba ┼╝e u┼╝ytkownik wyda┼é na to rozkaz. + +## 8. ARCHITEKTURA LOGISTYKI & DOSTAW +### Silnik: `api/courses/engine.php` +Unified REST API (POST, action-based) obs┼éuguj─ůcy Dispatcher i Driver App. +Akcje: `get_dashboard`, `dispatch`, `update_order_status`, `cancel_stop`, `start_shift`, `update_location`, `get_driver_runs`, `set_initial_cash`, `reconcile`, `set_driver_status`, `collect_payment`, `deliver_order`, `emergency_recall`, `check_recall`, `clear_recall`, `get_driver_wallet`. + +### Payment Lock (KRYTYCZNE) +Kierowca NIE MO┼╗E oznaczy─ç zam├│wienia jako "Dostarczono" je┼Ťli `payment_status != 'paid'`. Musi najpierw wywo┼éa─ç `collect_payment` (cash|card). Zam├│wienia op┼éacone online maj─ů `payment_status='paid'` od pocz─ůtku Ôćĺ one-click deliver. + +### K-System & L-Queues +- Kurs: `K{n}` ÔÇö dzienny sekwencyjny numer kursu per tenant (tabela `sh_course_sequences`). +- Przystanek: `L{n}` ÔÇö numer kolejny w kursie. +- Kierowca: `available` Ôćĺ `busy` (po dispatch) Ôćĺ `available` (po zako┼äczeniu kursu). + +### Emergency Recall +Dyspozytor Ôćĺ `emergency_recall` Ôćĺ ustawia `heading = -999` w `sh_driver_locations` Ôćĺ Driver App polluje `check_recall` Ôćĺ flash red overlay z wibracjami Ôćĺ `clear_recall` po potwierdzeniu. + +### Modu┼éy Frontend +- **Dispatcher:** `/modules/courses/` (Vanilla JS, Leaflet, Dark Glass theme) +- **Driver PWA:** `/modules/driver_app/` (Vanilla JS, 56px touch targets, safe-area-inset) + +## 9. IZOLACJA SILOS├ôW PREFIKSOWYCH (┼ÜWI─śTO┼Ü─ć) +Baza jest podzielona na trzy izolowane silosy po prefiksie. Ka┼╝dy silos ma w┼éasny ┼Ťwiat numerycznych ID i NIE WOLNO ich miesza─ç mi─Ödzy silosami. + +### Silosy +| Prefiks | Domena | Przyk┼éadowe tabele | +|---------|--------|---------------------| +| `sh_` | Biznes SliceHub (menu, orders, users, drivers, modyfikatory, receptury, promocje, tables, price_tiers) | `sh_menu_items`, `sh_orders`, `sh_order_lines`, `sh_recipes`, `sh_modifiers`, `sh_users`, `sh_drivers`, `sh_price_tiers`, `sh_product_mapping` | +| `sys_` | S┼éownik surowc├│w (master data magazynowe ÔÇö nazwa, jednostka, kategoria) | `sys_items` | +| `wh_` | Magazyn rzeczywisty (stany, dokumenty, linie, ruchy) | `wh_stock`, `wh_documents`, `wh_document_lines` | + +### Zasada ┼╝elazna +- Ôťů **Wewn─ůtrz jednego silosu** ÔÇö JOIN-y po numerycznym `id` s─ů OK (np. `sh_orders.id = sh_order_lines.order_id`, `wh_documents.id = wh_document_lines.document_id`). +- ÔŁî **BEZWZGL─śDNY ZAKAZ** JOIN-a / UPDATE-a mi─Ödzy silosami po numerycznym `id` (np. `sh_orders.id = wh_stock.something_id`, `sh_menu_items.id = sys_items.item_id`). Takie FK nie mog─ů nawet istnie─ç w schemacie. +- Ôťů **Cross-silo ┼é─ůczy si─Ö WY┼ü─äCZNIE przez klucze znakowe**: + - `sku` (VARCHAR) ÔÇö most mi─Ödzy `sh_` Ôćö `sys_` Ôćö `wh_`. + - `ascii_key` (VARCHAR) ÔÇö most w obr─Öbie `sh_` (menu Ôćö modifiers Ôćö price_tiers). + +### Referencyjne wzorce (TAK ÔÇö r├│b tak) +```sql +-- sh_ Ôćĺ sys_: ┼é─ůczenie receptury ze s┼éownikiem surowc├│w przez SKU +FROM sh_recipes r +JOIN sys_items s ON s.sku = r.warehouse_sku AND s.tenant_id = r.tenant_id + +-- sh_ Ôćĺ wh_: pobranie AVCO dla sk┼éadnika receptury przez SKU +FROM sh_recipes r +LEFT JOIN wh_stock ws ON ws.sku = r.warehouse_sku AND ws.tenant_id = r.tenant_id + +-- sh_ Ôćĺ wh_: pobranie AVCO dla modyfikatora przez SKU +FROM sh_modifiers m +LEFT JOIN wh_stock ws ON ws.sku = m.linked_warehouse_sku AND ws.tenant_id = :tid + +-- sys_ Ôćĺ wh_: widok stan├│w magazynowych z nazw─ů z dictionary +FROM sys_items s +LEFT JOIN wh_stock w ON w.sku = s.sku AND w.tenant_id = s.tenant_id +``` + +### Antywzorce (NIE ÔÇö nigdy) +```sql +-- ÔŁî NIE: numeryczny FK cross-silo +JOIN wh_stock ws ON ws.item_id = mi.id -- sh_menu_items.id Ôćĺ wh_ +JOIN sys_items s ON s.id = r.sys_item_id -- sh_recipes Ôćĺ sys_items.id +UPDATE sh_orders o JOIN wh_stock w ON w.id = o.warehouse_stock_id -- cross-silo UPDATE +``` + +### Bariera tenant_id w cross-silo JOIN +Ka┼╝dy most SKU MUSI mie─ç dodatkowy warunek `tenant_id` po obu stronach (┬ž2 + ┬ž9 ┼é─ůcznie): +```sql +JOIN sys_items s ON s.sku = r.warehouse_sku AND s.tenant_id = r.tenant_id +``` + +### Obowi─ůzek przy dodawaniu nowego prefiksu +Je┼Ťli w przysz┼éo┼Ťci powstanie nowy silos (np. `pay_`, `log_`, `aud_`), automatycznie obowi─ůzuje tu ta sama regu┼éa: most tylko przez klucze znakowe, nigdy po ID. \ No newline at end of file diff --git a/Untitled b/Untitled deleted file mode 100644 index 5240204..0000000 --- a/Untitled +++ /dev/null @@ -1 +0,0 @@ -03_MAPA_KOPALNI \ No newline at end of file diff --git a/_KOPALNIA_WIEDZY_LEGACY/api_pos.php b/_KOPALNIA_WIEDZY_LEGACY/api_pos.php deleted file mode 100644 index 9d44181..0000000 --- a/_KOPALNIA_WIEDZY_LEGACY/api_pos.php +++ /dev/null @@ -1,349 +0,0 @@ - $status, 'payload' => $payload, 'error' => $errorMsg]); - exit; -} - -// ­čÜĘ POPRAWKA 1: Pozwalamy te┼╝ roli 'driver' na dost─Öp do POS -require_role(['waiter', 'admin', 'manager', 'owner', 'driver']); - -$user_id = $_SESSION['user_id'] ?? 0; -$tenant_id = $_SESSION['tenant_id'] ?? 0; - -// ­čÜĘ POPRAWKA 2: Pobieranie akcji z inputu (TEGO BRAKOWA┼üO!) -$action = $input['action'] ?? $_GET['action'] ?? ''; - -function generate_uuid() { - return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0x0fff ) | 0x4000, mt_rand( 0, 0x3fff ) | 0x8000, mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ) ); -} - -// ­čÜÇ AUTO-MIGRACJA BAZY DANYCH (Niezb─Ödne dla K-Systemu, L-Kolejek i Po┼é├│wek) -try { $pdo->exec("ALTER TABLE sh_orders ADD COLUMN receipt_printed TINYINT(1) NOT NULL DEFAULT 0"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_orders ADD COLUMN kitchen_ticket_printed TINYINT(1) NOT NULL DEFAULT 0"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_orders ADD COLUMN edited_since_print TINYINT(1) NOT NULL DEFAULT 0"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_orders ADD COLUMN is_half TINYINT(1) NOT NULL DEFAULT 0"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_order_items ADD COLUMN is_half TINYINT(1) NOT NULL DEFAULT 0"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_order_items ADD COLUMN half_a_id INT DEFAULT NULL"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_order_items ADD COLUMN half_b_id INT DEFAULT NULL"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_orders ADD COLUMN course_id VARCHAR(20) DEFAULT NULL"); } catch (Exception $e) {} -try { $pdo->exec("ALTER TABLE sh_orders ADD COLUMN stop_number VARCHAR(10) DEFAULT NULL"); } catch (Exception $e) {} - -if ($action === 'get_init_data') { - $data = [ - 'categories' => $pdo->prepare("SELECT id, name AS name_utf8 FROM sh_categories WHERE tenant_id = ? AND is_menu = 1 ORDER BY display_order ASC"), - 'items' => $pdo->prepare("SELECT id, category_id, name, price, type FROM sh_menu_items WHERE tenant_id = ? AND is_deleted = 0"), - 'tables' => $pdo->prepare("SELECT id, table_number FROM sh_tables WHERE tenant_id = ?"), - 'ingredients' => $pdo->prepare("SELECT id, name, unit FROM sh_products WHERE tenant_id = ? AND is_active = 1"), - - // ­čÜĘ NAPRAWA ID KIEROWCY (u.id) ORAZ IMIENIA (NULLIF) - 'drivers' => $pdo->prepare("SELECT u.id, d.status, d.initial_cash, COALESCE(NULLIF(u.first_name, ''), u.username) AS first_name FROM sh_drivers d JOIN sh_users u ON d.user_id = u.id WHERE u.tenant_id = ?"), - - // ­čÜĘ NAPRAWA IMIENIA KELNERA - 'waiters' => $pdo->prepare("SELECT id, COALESCE(NULLIF(first_name, ''), username) AS first_name FROM sh_users WHERE tenant_id = ? AND role = 'waiter' AND is_active = 1") - ]; - foreach($data as $k => $stmt) { $stmt->execute([$tenant_id]); $data[$k] = $stmt->fetchAll(); } - sendResponse('success', $data); -} - -if ($action === 'get_orders') { - // Zwraca zam├│wienia wraz z K-System i L-Kolejkami - $stmt = $pdo->prepare(" - SELECT o.*, u.first_name as creator_name - FROM sh_orders o - LEFT JOIN sh_users u ON o.created_by = u.id - WHERE o.tenant_id = ? AND o.status NOT IN ('completed', 'cancelled') - ORDER BY COALESCE(o.promised_time, o.created_at) ASC - "); - $stmt->execute([$tenant_id]); - sendResponse('success', ['orders' => $stmt->fetchAll()]); -} - -if ($action === 'get_item_details') { - $item_id = (int)($_GET['item_id'] ?? $input['item_id'] ?? 0); - $half_b_id = (int)($_GET['half_b_id'] ?? $input['half_b_id'] ?? 0); - $stmt = $pdo->prepare("SELECT p.id as product_id, p.name as name_utf8 FROM sh_recipes r JOIN sh_products p ON r.product_id = p.id WHERE r.menu_item_id = ?"); - $stmt->execute([$item_id]); $ingA = $stmt->fetchAll(); foreach($ingA as &$i) $i['half'] = 'A'; - $ingredients = $ingA; - if ($half_b_id > 0) { $stmt->execute([$half_b_id]); $ingB = $stmt->fetchAll(); foreach($ingB as &$i) $i['half'] = 'B'; $ingredients = array_merge($ingredients, $ingB); } - sendResponse('success', ['ingredients' => $ingredients]); -} -if ($action === 'process_order') { - $edit_id = (int)($input['edit_order_id'] ?? 0); - $cart = $input['cart'] ?? []; - $new_cart_json = json_encode($cart); - $promised = $input['custom_datetime'] ? date('Y-m-d H:i:s', strtotime($input['custom_datetime'])) : date('Y-m-d H:i:s'); - $print_kitchen = (int)($input['print_kitchen'] ?? 0); - - $source = $input['source'] ?? 'local'; - - // TWARDA REGU┼üA: Zawsze pending na start, chyba ┼╝e to kelner (new) - $initial_status = $input['status'] ?? 'pending'; - if ($source === 'waiter' && $initial_status === 'new') { $print_kitchen = 0; } - - try { - $pdo->beginTransaction(); - $warehouse_id = 2; - - $stmtUnits = $pdo->prepare("SELECT id, unit FROM sh_products WHERE tenant_id = ?"); - $stmtUnits->execute([$tenant_id]); - $productUnits = []; - foreach($stmtUnits->fetchAll() as $row) { $productUnits[$row['id']] = strtolower(trim($row['unit'])); } - $stmtRecipe = $pdo->prepare("SELECT product_id, quantity, waste_percent FROM sh_recipes WHERE menu_item_id = ?"); - - if ($edit_id > 0) { - // EDYCJA ZAM├ôWIENIA (Wykrywanie Zmian na Kuchni─Ö) - $stmtOld = $pdo->prepare("SELECT cart_json, edited_since_print, kitchen_changes FROM sh_orders WHERE id = ? AND tenant_id = ?"); - $stmtOld->execute([$edit_id, $tenant_id]); - $oldOrder = $stmtOld->fetch(); - - $edited_flag = $oldOrder['edited_since_print'] ?? 0; - $kitchen_changes = $oldOrder['kitchen_changes'] ?? ''; - - if ($oldOrder && !empty($oldOrder['cart_json'])) { - if ($oldOrder['cart_json'] !== $new_cart_json) { - $edited_flag = 1; // Wyzwala ┼╝├│┼éty alarm - $diff_arr = []; - $oldCart = json_decode($oldOrder['cart_json'], true); - $oldMap = []; foreach($oldCart as $c) { $oldMap[$c['cart_id']] = $c; } - $newMap = []; foreach($cart as $c) { $newMap[$c['cart_id']] = $c; } - - foreach($newMap as $cid => $c) { - if(!isset($oldMap[$cid])) { $diff_arr[] = "DODANO: " . $c['qty'] . "x " . $c['name']; } - else { - if($oldMap[$cid]['qty'] != $c['qty']) { $diff_arr[] = "ZMIENIONO ILO┼Ü─ć: " . $c['name'] . " (" . $oldMap[$cid]['qty'] . " -> " . $c['qty'] . ")"; } - if(($oldMap[$cid]['comment'] ?? '') != ($c['comment'] ?? '')) { $diff_arr[] = "ZMIENIONO UWAGI DO: " . $c['name']; } - } - } - foreach($oldMap as $cid => $oc) { - if(!isset($newMap[$cid])) { $diff_arr[] = "USUNI─śTO: " . $oc['qty'] . "x " . $oc['name']; } - } - $kitchen_changes = implode(" | ", $diff_arr); - } - - // Zwracanie starego towaru (Logika magazynowa) - $oldCart = json_decode($oldOrder['cart_json'], true); - if (is_array($oldCart)) { - $stmtUpdateStockReturn = $pdo->prepare("UPDATE sh_stock_levels SET quantity = quantity + ? WHERE warehouse_id = ? AND product_id = ?"); - $stmtLogReturn = $pdo->prepare("INSERT INTO sh_inventory_logs (product_id, user_id, quantity_changed, action_type) VALUES (?, ?, ?, 'POS_EDIT_RETURN')"); - foreach ($oldCart as $item) { - $qty_sold = (float)$item['qty']; $products_to_return = []; $removed_ids = $item['removed'] ?? []; - $calcRecipe = function($menu_id, $multiplier) use ($stmtRecipe, &$products_to_return, $removed_ids) { - if(!$menu_id) return; $stmtRecipe->execute([$menu_id]); - foreach ($stmtRecipe->fetchAll() as $ing) { - $pid = $ing['product_id']; if (in_array($pid, $removed_ids)) continue; - $needed = ($ing['quantity'] * (1 + ($ing['waste_percent'] / 100))) * $multiplier; - if (!isset($products_to_return[$pid])) $products_to_return[$pid] = 0; $products_to_return[$pid] += $needed; - } - }; - if (!empty($item['is_half'])) { $calcRecipe($item['half_a'], 0.5 * $qty_sold); $calcRecipe($item['half_b'], 0.5 * $qty_sold); } - else { $calcRecipe($item['id'], 1.0 * $qty_sold); } - if (!empty($item['added'])) { - foreach ($item['added'] as $added_pid) { - $unit = $productUnits[$added_pid] ?? 'szt'; $extra_qty = 1.0; - if (in_array($unit, ['kg', 'litr', 'l'])) $extra_qty = 0.05; - if (!isset($products_to_return[$added_pid])) $products_to_return[$added_pid] = 0; - $products_to_return[$added_pid] += ($extra_qty * $qty_sold); - } - } - foreach ($products_to_return as $pid => $return_qty) { - $stmtUpdateStockReturn->execute([$return_qty, $warehouse_id, $pid]); - $stmtLogReturn->execute([$pid, $user_id, $return_qty]); - } - } - } - } - $pdo->prepare("DELETE FROM sh_order_items WHERE order_id = ?")->execute([$edit_id]); - - if ($print_kitchen === 1 && $source === 'local') { $edited_flag = 0; $kitchen_changes = ''; } - - $stmt = $pdo->prepare("UPDATE sh_orders SET type=?, payment_method=?, payment_status=?, total_price=?, address=?, customer_phone=?, nip=?, cart_json=?, promised_time=?, edited_since_print=?, kitchen_changes=? WHERE id=? AND tenant_id=?"); - $stmt->execute([$input['order_type'], $input['payment_method'], $input['payment_status'], $input['total_price'], $input['address'], $input['customer_phone'], $input['nip'] ?? '', $new_cart_json, $promised, $edited_flag, $kitchen_changes, $edit_id, $tenant_id]); - $order_id = $edit_id; - } else { - // NOWE ZAM├ôWIENIE - CODZIENNY RESET NUMERACJI - $uuid = generate_uuid(); - $stmtSeq = $pdo->prepare("SELECT COUNT(*) FROM sh_orders WHERE tenant_id = ? AND DATE(created_at) = CURDATE()"); - $stmtSeq->execute([$tenant_id]); - $seq = $stmtSeq->fetchColumn() + 1; - $order_number = 'ORD/' . date('Ymd') . '/' . str_pad($seq, 3, '0', STR_PAD_LEFT); - - $stmt = $pdo->prepare("INSERT INTO sh_orders (tenant_id, uuid, order_number, source, type, status, payment_method, payment_status, total_price, address, customer_phone, nip, cart_json, promised_time, kitchen_ticket_printed, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())"); - $stmt->execute([$tenant_id, $uuid, $order_number, $source, $input['order_type'], $initial_status, $input['payment_method'], $input['payment_status'], $input['total_price'], $input['address'], $input['customer_phone'], $input['nip'] ?? '', $new_cart_json, $promised, $print_kitchen, $user_id]); - $order_id = $pdo->lastInsertId(); - } - - // POBIERANIE NOWEGO TOWARU Z MAGAZYNU - $stmtUpdateStock = $pdo->prepare("UPDATE sh_stock_levels SET quantity = quantity - ? WHERE warehouse_id = ? AND product_id = ?"); - $stmtInsertStock = $pdo->prepare("INSERT INTO sh_stock_levels (warehouse_id, product_id, quantity) VALUES (?, ?, ?)"); - $stmtLog = $pdo->prepare("INSERT INTO sh_inventory_logs (product_id, user_id, quantity_changed, action_type) VALUES (?, ?, ?, 'POS_SALE')"); - foreach ($cart as $item) { - $qty_sold = (float)$item['qty']; - $stmtItem = $pdo->prepare("INSERT INTO sh_order_items (order_id, menu_item_id, snapshot_name, quantity, unit_price) VALUES (?, ?, ?, ?, ?)"); - $stmtItem->execute([$order_id, $item['id'] ?? null, $item['name'], $qty_sold, $item['price']]); - $products_to_deduct = []; $removed_ids = $item['removed'] ?? []; - $calcRecipe = function($menu_id, $multiplier) use ($stmtRecipe, &$products_to_deduct, $removed_ids) { - if(!$menu_id) return; $stmtRecipe->execute([$menu_id]); - foreach ($stmtRecipe->fetchAll() as $ing) { - $pid = $ing['product_id']; if (in_array($pid, $removed_ids)) continue; - $needed = ($ing['quantity'] * (1 + ($ing['waste_percent'] / 100))) * $multiplier; - if (!isset($products_to_deduct[$pid])) $products_to_deduct[$pid] = 0; $products_to_deduct[$pid] += $needed; - } - }; - if (!empty($item['is_half'])) { $calcRecipe($item['half_a'], 0.5 * $qty_sold); $calcRecipe($item['half_b'], 0.5 * $qty_sold); } else { $calcRecipe($item['id'], 1.0 * $qty_sold); } - if (!empty($item['added'])) { - foreach ($item['added'] as $added_pid) { - $unit = $productUnits[$added_pid] ?? 'szt'; $extra_qty = 1.0; - if (in_array($unit, ['kg', 'litr', 'l'])) $extra_qty = 0.05; - if (!isset($products_to_deduct[$added_pid])) $products_to_deduct[$added_pid] = 0; $products_to_deduct[$added_pid] += ($extra_qty * $qty_sold); - } - } - foreach ($products_to_deduct as $pid => $final_qty) { - $stmtUpdateStock->execute([$final_qty, $warehouse_id, $pid]); - if ($stmtUpdateStock->rowCount() === 0) { $stmtInsertStock->execute([$warehouse_id, $pid, -$final_qty]); } - $stmtLog->execute([$pid, $user_id, -$final_qty]); - } - } - - if (($input['print_receipt'] ?? 0) == 1) { - $pdo->prepare("UPDATE sh_orders SET receipt_printed=1 WHERE id=?")->execute([$order_id]); - } - - $pdo->commit(); sendResponse('success', ['order_id' => $order_id]); - } catch (Exception $e) { $pdo->rollBack(); sendResponse('error', null, $e->getMessage()); } -} -if ($action === 'accept_order') { - $parsed_time = date('Y-m-d H:i:s', strtotime($input['custom_time'])); - $pdo->prepare("UPDATE sh_orders SET status='pending', promised_time=?, kitchen_ticket_printed=1 WHERE id=? AND tenant_id=?")->execute([$parsed_time, $input['order_id'], $tenant_id]); - sendResponse('success'); -} - -if ($action === 'update_status') { - $pdo->prepare("UPDATE sh_orders SET status=? WHERE id=? AND tenant_id=?")->execute([$input['status'], $input['order_id'], $tenant_id]); - sendResponse('success'); -} - -if ($action === 'print_kitchen') { - $pdo->prepare("UPDATE sh_orders SET kitchen_ticket_printed=1, edited_since_print=0, kitchen_changes='' WHERE id=?")->execute([$input['order_id']]); - sendResponse('success'); -} - -if ($action === 'print_receipt') { - $method = $input['payment_method'] ?? 'unpaid'; - $pdo->prepare("UPDATE sh_orders SET receipt_printed=1, payment_method=? WHERE id=?")->execute([$method, $input['order_id']]); - sendResponse('success'); -} - -if ($action === 'settle_and_close') { - $print = (int)($input['print_receipt'] ?? 0); - $method = $input['payment_method'] ?? ''; - $order_id = $input['order_id'] ?? 0; - - $stmtCheck = $pdo->prepare("SELECT receipt_printed FROM sh_orders WHERE id = ?"); - $stmtCheck->execute([$order_id]); - $already_printed = $stmtCheck->fetchColumn(); - - if (($method === 'card' || $method === 'online') && $print === 0 && $already_printed == 0) { - sendResponse('error', null, 'Dla karty lub online wydruk paragonu jest obowi─ůzkowy!'); - } - - $sql = "UPDATE sh_orders SET payment_status='paid', payment_method=?, status='completed'"; - if($print === 1) $sql .= ", receipt_printed=1"; - $sql .= " WHERE id=?"; - $pdo->prepare($sql)->execute([$method, $order_id]); - sendResponse('success'); -} - -if ($action === 'cancel_order') { - $order_id = (int)($input['order_id'] ?? 0); - $return_stock = (int)($input['return_stock'] ?? 0); - try { - $pdo->beginTransaction(); - $pdo->prepare("UPDATE sh_orders SET status='cancelled' WHERE id=?")->execute([$order_id]); - if ($return_stock === 1) { - $stmtOld = $pdo->prepare("SELECT cart_json FROM sh_orders WHERE id = ? AND tenant_id = ?"); - $stmtOld->execute([$order_id, $tenant_id]); - $oldOrder = $stmtOld->fetch(); - if ($oldOrder && !empty($oldOrder['cart_json'])) { - $oldCart = json_decode($oldOrder['cart_json'], true); - $stmtUpdateStockReturn = $pdo->prepare("UPDATE sh_stock_levels SET quantity = quantity + ? WHERE warehouse_id = 2 AND product_id = ?"); - $stmtLogReturn = $pdo->prepare("INSERT INTO sh_inventory_logs (product_id, user_id, quantity_changed, action_type) VALUES (?, ?, ?, 'POS_CANCEL_RETURN')"); - $stmtRecipe = $pdo->prepare("SELECT product_id, quantity, waste_percent FROM sh_recipes WHERE menu_item_id = ?"); - $stmtUnits = $pdo->prepare("SELECT id, unit FROM sh_products WHERE tenant_id = ?"); - $stmtUnits->execute([$tenant_id]); - $productUnits = []; - foreach($stmtUnits->fetchAll() as $row) { $productUnits[$row['id']] = strtolower(trim($row['unit'])); } - foreach ($oldCart as $item) { - $qty_sold = (float)$item['qty']; $products_to_return = []; $removed_ids = $item['removed'] ?? []; - $calcRecipe = function($menu_id, $multiplier) use ($stmtRecipe, &$products_to_return, $removed_ids) { - if(!$menu_id) return; $stmtRecipe->execute([$menu_id]); - foreach ($stmtRecipe->fetchAll() as $ing) { - $pid = $ing['product_id']; if (in_array($pid, $removed_ids)) continue; - $needed = ($ing['quantity'] * (1 + ($ing['waste_percent'] / 100))) * $multiplier; - if (!isset($products_to_return[$pid])) $products_to_return[$pid] = 0; $products_to_return[$pid] += $needed; - } - }; - if (!empty($item['is_half'])) { $calcRecipe($item['half_a'], 0.5 * $qty_sold); $calcRecipe($item['half_b'], 0.5 * $qty_sold); } - else { $calcRecipe($item['id'], 1.0 * $qty_sold); } - if (!empty($item['added'])) { - foreach ($item['added'] as $added_pid) { - $unit = $productUnits[$added_pid] ?? 'szt'; $extra_qty = 1.0; - if (in_array($unit, ['kg', 'litr', 'l'])) $extra_qty = 0.05; - if (!isset($products_to_return[$added_pid])) $products_to_return[$added_pid] = 0; $products_to_return[$added_pid] += ($extra_qty * $qty_sold); - } - } - foreach ($products_to_return as $pid => $return_qty) { - $stmtUpdateStockReturn->execute([$return_qty, $pid]); - $stmtLogReturn->execute([$pid, $user_id, $return_qty]); - } - } - } - } - $pdo->commit(); sendResponse('success'); - } catch (Exception $e) { $pdo->rollBack(); sendResponse('error', null, $e->getMessage()); } -} - -// ­čÜÇ ZAAWANSOWANA WYSY┼üKA W TRAS─ś (SYSTEM K & L) -if ($action === 'assign_route') { - $driver_id = (int)($input['driver_id'] ?? 0); - $order_ids = $input['order_ids'] ?? []; - - if (!$driver_id || empty($order_ids)) { - sendResponse('error', null, 'Wybierz kierowc─Ö i zam├│wienia.'); - } - - try { - $pdo->beginTransaction(); - - // Obliczamy nowy unikalny numer kursu dla dnia dzisiejszego (K1, K2...) - $stmtK = $pdo->prepare("SELECT COUNT(DISTINCT course_id) FROM sh_orders WHERE tenant_id = ? AND DATE(created_at) = CURDATE() AND course_id IS NOT NULL"); - $stmtK->execute([$tenant_id]); - $next_k = $stmtK->fetchColumn() + 1; - $course_id = 'K' . $next_k; - - $stmtUpdate = $pdo->prepare("UPDATE sh_orders SET status='in_delivery', driver_id=?, course_id=?, stop_number=? WHERE id=? AND tenant_id=?"); - - // Przypisanie numeracji kolejno┼Ťci wyjazdu (L1, L2...) do konkretnych zam├│wie┼ä - $l_num = 1; - foreach ($order_ids as $oid) { - $stmtUpdate->execute([$driver_id, $course_id, 'L' . $l_num, $oid, $tenant_id]); - $l_num++; - } - - $pdo->commit(); - sendResponse('success', ['course_id' => $course_id]); - } catch (Exception $e) { - $pdo->rollBack(); - sendResponse('error', null, $e->getMessage()); - } -} - -if ($action === 'panic_mode') { - $pdo->prepare("UPDATE sh_orders SET promised_time = DATE_ADD(COALESCE(promised_time, created_at), INTERVAL 20 MINUTE) WHERE status IN ('pending', 'ready') AND tenant_id = ?")->execute([$tenant_id]); - sendResponse('success', ['message' => 'Wyd┼éu┼╝ono czasy o 20 minut!']); -} - -sendResponse('error', null, 'Brak akcji.'); -?> \ No newline at end of file diff --git a/_KOPALNIA_WIEDZY_LEGACY/order_handler_view (1).html b/_KOPALNIA_WIEDZY_LEGACY/order_handler_view (1).html deleted file mode 100644 index 03905fc..0000000 --- a/_KOPALNIA_WIEDZY_LEGACY/order_handler_view (1).html +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - SliceHub POS - Zam├│wienia Online - - - - -
-
-

SLICEHUB POS - Zam├│wienia Online & KDS

-
Integracja: Własna strona WWW + Kiosk | Widok: Sala/Kuchnia
-
- Silnik: OrderHandler API -
- -
-
-
- -
-

Oczekuj─ůce (Nowe) 1

- -
-
- #WEB-4021 - Strona WWW -
-
- 14:02 (3 min temu) - Opłacone (PayU) -
-
- 1x Pizza Parma (32cm) - - BEZ RUKOLI - 1x Sos Czosnkowy -
- -
-
- -
-

W przygotowaniu 2

- -
-
- #KIO-108 - Kiosk Lokal -
-
- 13:50 (12 min temu) - Odbi├│r: Na miejscu -
-
- 2x Burger Drwala - 1x Frytki Du┼╝e -
- -
- -
-
- #WEB-4020 - Strona WWW -
-
- 13:45 (17 min temu) - Dostawa: ul. Polna 12 -
-
- 3x Pizza Margherita -
- -
-
- -
-

Gotowe / Wydane 1

- -
-
- #WEB-4019 - Strona WWW -
-
- Wydano kierowcy: Jan - Opłacone -
-
- 1x Zestaw Sushi (M) -
-
-
- -
-
- -
-

Konfiguracja Odbioru

-

Plik docelowy: orders_settings.php

- -
-

Automatyzacja Zleceń

-
- Auto-akceptuj zlecenia z Kiosku - -
-
- Auto-akceptuj WWW (Tylko opłacone) - -
-
- Domy┼Ťlny czas dostawy (min) - -
-
- -
-

Integracje API (Zewn─Ötrzne)

-
- Pobieraj zlecenia z Glovo/UberEats - -
-
- Zdejmuj surowce (PROD) w locie - -
-
- -
- KRYTYCZNE PRZECIĄŻENIE KUCHNI? - -

Wy┼é─ůcza mo┼╝liwo┼Ť─ç zamawiania na stronie WWW i w Kiosku do odwo┼éania.

-
- -
-
- - - \ No newline at end of file diff --git a/_KOPALNIA_WIEDZY_LEGACY/pos (1).html b/_KOPALNIA_WIEDZY_LEGACY/pos (1).html deleted file mode 100644 index 04d170b..0000000 --- a/_KOPALNIA_WIEDZY_LEGACY/pos (1).html +++ /dev/null @@ -1,1147 +0,0 @@ - - - - - - SliceHub POS v6.5 ULTIMATE - - - - - - - - - - - -
- - - -
- -
-
-

NOWE ONLINE

- -
-
-
-

­čŤÁ Dow├│z

-
-
-
-

­čąí Wynos

-
-
-
-

­čŹŻ´ŞĆ Sala

-
-
-
-
- -
-
-
- -
-
-
-

Flota

- -
-
-
-
-
-

Kelnerzy

-
-
-
-
-
-

Helpdesk

- pomoc@slicehub.pl -
- -
-
-
- - -
-
-
- -
- -
-
-
-
-
- -
-
-

Koszyk

-
-
- - - -
- -
-
- -
- Suma - 0.00 zł -
- -
-
-
- - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/_KOPALNIA_WIEDZY_LEGACY/pos_active_routes.js b/_KOPALNIA_WIEDZY_LEGACY/pos_active_routes.js deleted file mode 100644 index e2f5f3a..0000000 --- a/_KOPALNIA_WIEDZY_LEGACY/pos_active_routes.js +++ /dev/null @@ -1,150 +0,0 @@ -// ========================================== -// ­čÜÜ SLICEHUB - BATTLEFIELD KURS├ôW (pos_active_routes.js) -// ========================================== - -function renderActiveRoutes() { - const grid = document.getElementById('bf-grid'); - let routeGroups = {}; - - // 1. Grupowanie zam├│wie┼ä w trasie (in_delivery) - state.orders.forEach(o => { - if (o.status === 'in_delivery' && o.course_id) { - if (!routeGroups[o.course_id]) { - routeGroups[o.course_id] = { - driver_id: o.driver_id, - course_id: o.course_id, - items: [], - cashToCollect: 0, - cardToCollect: 0, - paidTotal: 0 - }; - } - routeGroups[o.course_id].items.push(o); - - let price = parseFloat(o.total_price) || 0; - - // BEZWZGL─śDNA LOGIKA PORTFELA KIEROWCY - if (o.payment_status === 'paid') { - routeGroups[o.course_id].paidTotal += price; - } else { - if (o.payment_method === 'card') { - routeGroups[o.course_id].cardToCollect += price; - } else { - // Domy┼Ťlnie wszystko nieop┼éacone co nie jest kart─ů to got├│wka - routeGroups[o.course_id].cashToCollect += price; - } - } - } - }); - - const activeRouteKeys = Object.keys(routeGroups); - - // Pusty stan - if (activeRouteKeys.length === 0) { - grid.innerHTML = ` -
- -

Brak Aktywnych Kurs├│w

-

Wszystkie zam├│wienia zosta┼éy rozliczone lub czekaj─ů na kuchni.

-
`; - return; - } - - let html = '
'; - - activeRouteKeys.forEach(k => { - const g = routeGroups[k]; - const driver = state.drivers.find(d => d.id == g.driver_id) || { first_name: 'Nieznany', initial_cash: 0 }; - const initialCash = parseFloat(driver.initial_cash || 0); - const totalCashToReturn = initialCash + g.cashToCollect; - - // Sortowanie po przystankach L1, L2... - g.items.sort((a, b) => { - let numA = parseInt((a.stop_number || 'L99').replace('L', '')); - let numB = parseInt((b.stop_number || 'L99').replace('L', '')); - return numA - numB; - }); - - // Generowanie Bonów Przystanków - const stopsHtml = g.items.map(o => { - let payBadge = ''; - if (o.payment_status === 'paid') { - payBadge = `OPŁACONE (${(o.payment_method||'').toUpperCase()}) - NIE POBIERAJ`; - } else if (o.payment_method === 'card') { - payBadge = `KARTA (WEŹ TERMINAL)`; - } else { - payBadge = `GOTÓWKA DO POBRANIA`; - } - - return ` -
-
-
-
${o.stop_number || '-'}
-
-

#${o.order_number.split('/').pop()} ÔÇó ${o.address}

-

${o.customer_phone || 'Brak telefonu'}

-
-
-
- ${o.total_price} zł -
${payBadge}
-
-
`; - }).join(''); - - // Budowa Głównej Karty Kursu - html += ` -
- -
-
-
- ${g.course_id.replace('K', '')} -
-
-

${driver.first_name}

-

Wyjazd: ${g.items[0].promised_time ? g.items[0].promised_time.substring(11, 16) : '--:--'}

-
-
-
- - - -
-
- -
-
- Gotówka (Start) - ${initialCash.toFixed(2)} zł -
-
- P┼éatno┼Ť─ç Kart─ů - ${g.cardToCollect.toFixed(2)} z┼é -
-
- Opłacone z Góry - ${g.paidTotal.toFixed(2)} zł -
-
- ZBIERZ GOT├ôWK─ś - + ${g.cashToCollect.toFixed(2)} z┼é -
-
- -
- Gotówka do zdania po powrocie: - ${totalCashToReturn.toFixed(2)} zł -
- -
-

Przystanki na trasie (${g.items.length})

- ${stopsHtml} -
-
`; - }); - - html += '
'; - grid.innerHTML = html; -} \ No newline at end of file diff --git a/_KOPALNIA_WIEDZY_LEGACY/pos_fleet.js b/_KOPALNIA_WIEDZY_LEGACY/pos_fleet.js deleted file mode 100644 index d221f6a..0000000 --- a/_KOPALNIA_WIEDZY_LEGACY/pos_fleet.js +++ /dev/null @@ -1,97 +0,0 @@ -// ========================================== -// ­čÜÜ SLICEHUB - MODU┼ü FLOTY I KURS├ôW (pos_fleet.js) -// ========================================== - -function toggleOrderToRoute(id, ev) { - if(ev) ev.stopPropagation(); - const numericId = Number(id); - if(state.routeOrders.includes(numericId)) { state.routeOrders = state.routeOrders.filter(i => i !== numericId); } - else { state.routeOrders.push(numericId); } - if(typeof renderDrivers === 'function') renderDrivers(); - if(typeof renderOrders === 'function') renderOrders(); -} - -function toggleRouteMode(id) { - state.routeDriverId = state.routeDriverId === id ? null : id; - state.routeOrders = []; - if(state.routeDriverId) { showToast("Wybierz zam├│wienia z listy, aby zbudowa─ç kurs.", "info"); } - renderDrivers(); renderOrders(); -} - -async function sendRoute() { - if(state.routeOrders.length === 0 || !state.routeDriverId) return; - - const btnSend = document.getElementById('btn-send-route'); - const orgText = btnSend.innerHTML; - btnSend.innerHTML = ' Wysy┼éanie...'; - - const d = await apiPost('assign_route', {driver_id: state.routeDriverId, order_ids: state.routeOrders}); - - if(d.status === 'success') { - showToast("Wygenerowano i wys┼éano kurs do kierowcy!", "success"); - state.routeDriverId = null; - state.routeOrders = []; - - // ­čÜĘ TWARDE PRZE┼ü─äCZENIE FILTRA - state.filterType = 'routes'; - if(typeof setFilter === 'function') { - setFilter('routes'); - } - - await fetchOrders(); // Czekamy na nowe dane z bazy - if(typeof renderActiveRoutes === 'function') renderActiveRoutes(); // Wymuszamy render Battlefielda - } else { - showToast(d.error, "error"); - btnSend.innerHTML = orgText; - } -} - -function renderDrivers() { - const list = document.getElementById('drivers-list'); - const btnSend = document.getElementById('btn-send-route'); - - if(state.routeDriverId && state.routeOrders.length > 0) { - btnSend.classList.remove('hidden'); - btnSend.innerText = `Wy┼Ťlij Kurs (${state.routeOrders.length})`; - } else { - btnSend.classList.add('hidden'); - } - - list.innerHTML = state.drivers.filter(d => d.status !== 'offline').map(d => { - const active = state.orders.filter(o => o.driver_id == d.id && o.status === 'in_delivery'); - const returning = state.orders.filter(o => o.driver_id == d.id && o.status === 'delivered'); - const isSelected = state.routeDriverId === d.id; - - let courseTags = ''; - if (active.length > 0) { - let uniqueCourses = [...new Set(active.map(o => o.course_id).filter(c => c))]; - if (uniqueCourses.length > 0) { - courseTags = uniqueCourses.map(c => `${c}`).join(''); - } - } - - let s = "Dost─Öpny"; let c = "text-green-500"; let b = "border-white/5 bg-black/40"; let dot = "bg-green-500"; - if(returning.length > 0 && active.length === 0) { s = "Wraca do bazy"; c = "text-yellow-400 animate-pulse"; b = "border-yellow-500/30 bg-yellow-900/10"; dot = "bg-yellow-500"; } - else if(active.length > 0) { s = `W trasie (${active.length})`; c = "text-red-500"; b = "border-red-500/30 bg-red-900/10"; dot = "bg-red-500"; } - if (isSelected) { b = "border-blue-500 bg-blue-900/20 shadow-[0_0_15px_rgba(59,130,246,0.3)]"; } - - return ` -
-
- - -
-
-

${d.first_name} ${courseTags}

-

${s}

-
-
`; - }).join(''); - if (!list.innerHTML) list.innerHTML = '
Brak kierowc├│w online
'; -} - -function renderWaiters() { - const list = document.getElementById('waiters-list'); - list.innerHTML = state.waiters.map(w => `

${w.first_name}

Na Sali

`).join(''); - if (!list.innerHTML) list.innerHTML = '
Brak kelner├│w
'; -} \ No newline at end of file diff --git a/_docs/02_ARCHITEKTURA.md b/_docs/02_ARCHITEKTURA.md index a3eee81..e9c7fc3 100644 --- a/_docs/02_ARCHITEKTURA.md +++ b/_docs/02_ARCHITEKTURA.md @@ -1,38 +1,388 @@ -# ARCHITEKTURA SYSTEMU I MAPA KATALOG├ôW - -Ten dokument s┼éu┼╝y Agentom AI (np. Cursor) jako oficjalna mapa drogowa po projekcie SliceHub Enterprise. Nie pr├│buj zgadywa─ç, gdzie s─ů pliki ani nie przeszukuj ca┼éego dysku w ciemno ÔÇô sprawdzaj struktur─Ö tutaj. - -## 1. G┼ü├ôWNE STREFY SYSTEMU (PRODUKCJA) - -### A. BACKOFFICE (STUDIO) - Silnik Zarz─ůdzania Menu -**Katalog:** `/modules/studio/` -To jest nowoczesne serce zarz─ůdzania systemem (Menu, Ceny, KSeF). -- `index.html` - G┼é├│wny szkielet i interfejs Studio. -- `studio_ui.js` - Renderowanie drzewa menu, kategorii i checkboxy zaznaczania masowego. -- `studio_item.js` - Edytor pojedynczego dania oraz Macierzy Cenowej (Omnichannel). -- `studio_modifiers.js` - Obs┼éuga Bli┼║niaka Cyfrowego, u┼éamk├│w zu┼╝ycia surowc├│w i akcji ADD/REMOVE. -- `studio_bulk.js` - Pot─Ö┼╝ny silnik Edycji Masowej wysy┼éaj─ůcy masowe modyfikacje cenowe i temporalne. - -### B. BATTLEFIELD (POS) - Strefa Operacyjna Front-Line -**Katalog:** `/modules/pos/` (w trakcie budowy/migracji) -To g┼é├│wny ekran restauracji dla za┼éogi. -G┼é├│wne modu┼éy operacyjne (docelowe): -- **The Pulse:** W─ůska kolumna agreguj─ůca zam├│wienia z w┼éasnej strony i portali (Delivery/Online). -- **The Panic Button:** System masowego zarz─ůdzania kryzysowego (op├│┼║nienia, SMS Alerting). -- **Battlefield Main:** Centralny ekran wydawki, podzia┼é na sekcje i nabijanie na sal─Ö. - -### C. BACKEND API (PHP) -**Katalog:** `/api/backoffice/` -- `api_menu_studio.php` - G┼é├│wny plik operacyjny (router/switch) odbieraj─ůcy ┼╝─ůdania ze Studio. -- Wszystkie po┼é─ůczenia z API wymagaj─ů strukturalnych obiekt├│w JSON (np. `omnichannelPricePatch`, `temporalPublicationPatch`), zakaz przesy┼éania p┼éaskich warto┼Ťci zast─Öpuj─ůcych stare struktury bazy danych. - -## 2. STREFA KWARANTANNY (KOD LEGACY) - -### KOPALNIA WIEDZY / Z┼üOMOWISKO (Dawca Organ├│w) -**Katalog:** `/_KOPALNIA_WIEDZY_LEGACY/` (lub podobny katalog ze starymi plikami) -Tutaj znajduje si─Ö stary kod (m.in. Magazyn, Grywalizacja za┼éogi, stary POS, system kurierski, ponad 60 plik├│w PHP/HTML). - -**BEZWZGL─śDNA ZASADA DLA AI:** 1. Ca┼éy ten folder ma status **STRICTLY READ-ONLY** (Tylko do odczytu). -2. Masz absolutny zakaz edytowania tamtych plik├│w. -3. Masz zakaz linkowania tych starych plik├│w do nowego interfejsu (np. do `index.html`). -4. S┼éu┼╝─ů one wy┼é─ůcznie jako encyklopedia zasad biznesowych. Kiedy tworzysz now─ů funkcj─Ö, masz przeczyta─ç stary plik z kopalni, zrozumie─ç jego logik─Ö dzia┼éania (np. punktacj─Ö w grywalizacji), a nast─Öpnie napisa─ç CA┼üKOWICIE NOWY, zoptymalizowany kod w odpowiednim folderze produkcyjnym, zachowuj─ůc zgodno┼Ť─ç z nasz─ů Konstytucj─ů. \ No newline at end of file +# ARCHITEKTURA SYSTEMU ÔÇö MAPA KATALOG├ôW + +> Oficjalna mapa drogowa projektu **SliceHub Enterprise** dla Agent├│w AI. +> Nie zgaduj ÔÇö sprawdzaj struktur─Ö tutaj. +> +> **Ostatnia synchronizacja:** 2026-04-19 (po audycie modu┼é├│w). +> **North Star:** [`_docs/00_PAMIEC_SYSTEMU.md`](00_PAMIEC_SYSTEMU.md) ÔÇö master reference. + +--- + +## 1. FRONTEND ÔÇö Modu┼éy UI (`/modules/`) + +### A. STUDIO ÔÇö Silnik Zarz─ůdzania Menu +`/modules/studio/` + +| Plik | Rola | +|------|------| +| `index.html` | Szkielet i interfejs Studio | +| `js/studio_core.js` | Core + wsp├│┼édzielona logika (wo┼éa `api/backoffice/api_menu_studio.php`) | +| `js/studio_ui.js` | Drzewo menu, kategorie, zaznaczanie masowe | +| `js/studio_item.js` | Edytor dania + Macierz Cenowa (Omnichannel) | +| `js/studio_modifiers.js` | Bli┼║niak Cyfrowy, zu┼╝ycie surowc├│w, akcje ADD/REMOVE | +| `js/studio_recipe.js` | Edytor receptur (surowce Ôćĺ dania) | +| `js/studio_bulk.js` | Edycja Masowa (ceny, publikacja temporalna) | +| `js/studio_margin.js` | Kalkulator mar┼╝y | + +> ÔÜá **D┼üUG TECHNICZNY:** Studio NIE posiada dedykowanego `studio_api.js`. Ka┼╝dy plik wywo┼éuje `window.ApiClient.post('api/backoffice/api_menu_studio.php', ÔÇŽ)` bezpo┼Ťrednio. Planowany refactor do sp├│jnego wrappera. + +### B. POS ÔÇö Strefa Operacyjna (Dark Battlefield) +`/modules/pos/` + +| Plik | Rola | +|------|------| +| `index.html` | Szkielet POS (kafelki + koszyk + checkout) | +| `js/pos_app.js` | Kontroler g┼é├│wny: auth, menu, koszyk, checkout | +| `js/pos_api.js` | API wrapper Ôćĺ `api/pos/engine.php`, `api/auth/login.php`, `api/courses/engine.php`, `api/tables/engine.php` | +| `js/pos_cart.js` | Logika koszyka (UI ÔÇö prawda zawsze z serwera) | +| `js/pos_ui.js` | Rendering UI | +| `css/style.css` | Dark Battlefield theme | + +### C. TABLES ÔÇö Modu┼é Kelnerski (Stoliki) +`/modules/tables/` + +| Plik | Rola | +|------|------| +| `index.html` | Plan sali + listy zam├│wie┼ä otwartych | +| `js/tables_app.js` | Kontroler stolik├│w, otwieranie/zamykanie rachunk├│w, transfery | +| `js/tables_api.js` | API wrapper Ôćĺ `api/tables/engine.php`, `api/auth/login.php` (strict JWT) | +| `css/style.css` | Dark Glass theme | + +### D. WAITER ÔÇö Mobile Waiter App +`/modules/waiter/` + +| Plik | Rola | +|------|------| +| `index.html` | PIN login + mobilny interfejs kelnera | +| `js/waiter_app.js` | Monolit: PIN login + wywo┼éania `api/tables/engine.php` + UI | + +> ÔÜá **D┼üUG TECHNICZNY:** Waiter u┼╝ywa bezpo┼Ťredniego `fetch`, nie ma `waiter_api.js`. Do refaktoru. + +### E. DISPATCHER ÔÇö Centrum Logistyki (Kursy) +`/modules/courses/` + +| Plik | Rola | +|------|------| +| `index.html` | 3 zak┼éadki: Zam├│wienia / Mapa / Aktywne Kursy | +| `js/courses_app.js` | Auth PIN, polling 8s, dispatch workflow, modals (cash/reconcile) | +| `js/courses_api.js` | API wrapper Ôćĺ `api/courses/engine.php`, `api/auth/login.php` | +| `js/courses_map.js` | Leaflet.js: markery zam├│wie┼ä + kierowc├│w (Carto Dark) | +| `js/courses_ui.js` | Karty zam├│wie┼ä, kierowc├│w, kurs├│w, SLA badges, wallet, toast | +| `css/style.css` | Dark Glass theme | + +Workflow: `ready` + `available` Ôćĺ Select driver + orders Ôćĺ Dispatch Ôćĺ Kurs `Kn` z przystankami `L1..Ln` + +Features: multi-order dispatch, Leaflet map, active courses z per-course rozliczeniem (cash/card/prepaid), pogotowie kasowe, reconciliation modal, Emergency Recall, SLA badges. + +### F. DRIVER APP ÔÇö Aplikacja Mobilna PWA +`/modules/driver_app/` + +| Plik | Rola | +|------|------| +| `index.html` | PIN login + bottom tab bar (Kursy / Portfel) | +| `js/driver_app.js` | Auth, polling 10s, GPS 15s, payment lock, emergency recall | +| `js/driver_api.js` | API wrapper Ôćĺ `api/courses/engine.php`, `api/auth/login.php` | +| `css/style.css` | High-contrast dark, touch 56px+, safe-area-inset, PWA | +| `manifest.json` | PWA manifest (standalone, portrait) | + +Critical: Payment Lock, Driver Wallet, Emergency Alert (red flash + vibration), GPS do `sh_driver_locations`. + +### G. KDS ÔÇö Kitchen Display System +`/modules/kds/` + +| Plik | Rola | +|------|------| +| `index.html` | Tablica kuchenna (tickets grid) | +| `js/kds_app.js` | Polling 6s, bump (accept Ôćĺ preparing Ôćĺ ready), recall, Bearer auth | +| `css/style.css` | Kitchen display theme | + +> Ôä╣ Dzia┼éa z ka┼╝d─ů sesj─ů logowan─ů (JWT lub session cookie). Po 401 pokazuje lock-screen z instrukcj─ů logowania. + +### H. ONLINE ÔÇö Publiczna Witryna (The Surface) +`/modules/online/` + +| Plik | Rola | +|------|------| +| `index.html` | Publiczna karta menu | +| `track.html` | Tracking zam├│wienia klienta (odzysk po `tracking_token` + telefon) | +| `js/online_app.js` | G┼é├│wny bootstrap | +| `js/online_api.js` | API wrapper Ôćĺ `api/online/engine.php` (bez JWT ÔÇö publiczne; tenant z `meta[name="sh-tenant-id"]` albo `?tenant=`) | +| `js/online_renderer.js` | Rendering sceny (t┼éa, warstwy, companions) | +| `js/online_checkout.js` | Checkout go┼Ť─ç (`init_checkout` + `guest_checkout`) | +| `js/online_table.js` | Otwarcie stolika przez QR | +| `js/online_track.js` | Logika ┼Ťledzenia (`track_order`, polling 10s) | +| `js/online_ui.js` | UI atoms | +| `css/style.css`, `css/track.css` | The Surface theme | + +### I. ONLINE STUDIO ÔÇö Re┼╝yser Sceny (Director) +`/modules/online_studio/` + +> ÔÜá Modu┼é aktualnie pod aktywnym rozwojem ÔÇö start od `START_TUTAJ.md`, potem `00_PAMIEC_SYSTEMU.md` i `15_KIERUNEK_ONLINE.md`. Struktura: `js/director/DirectorApp.js` + `js/tabs/*` + `js/studio_api.js` Ôćĺ `api/online_studio/engine.php`, `api/assets/engine.php`, `api/backoffice/api_menu_studio.php`, `api/online_studio/library_upload.php`. + +### J. WAREHOUSE ÔÇö Modu┼é Magazynowy +`/modules/warehouse/` + +| Plik | Rola | +|------|------| +| `index.html` | Dashboard (stany, dokumenty, alerty) | +| `manager_pz.html` | Przyj─Öcie towaru (PZ) | +| `manager_rw.html` | Rozch├│d wewn─Ötrzny (RW) | +| `manager_in.html` | Inwentaryzacja (INW) | +| `manager_kor.html` | Korekta (KOR) | +| `manager_mm.html` | Przesuni─Öcie mi─Ödzymagazynowe (MM) | +| `js/warehouse_core.js` | Logika wsp├│┼édzielona | +| `js/warehouse_api.js` | API wrapper Ôćĺ `api/warehouse/*.php` (wiele endpoint├│w) | + +### K. SETTINGS ÔÇö Panel Konfiguracyjny +`/modules/settings/` + +Panel ustawie┼ä tenanta, konfiguracja integracji, webhook├│w, stawek VAT, zmianowych stawek payroll. Opisany w `_docs/13_SETTINGS_PANEL.md`. + +--- + +## 2. BACKEND ÔÇö API & Core + +### Endpointy `/api/` + +#### Auth & sesje +| ┼Ücie┼╝ka | Opis | +|---------|------| +| `auth/login.php` | Auth (mode: `system` / `kiosk`), zwraca JWT | + +#### Core engines ÔÇö routing przez `engine.php` +| ┼Ücie┼╝ka | Opis | +|---------|------| +| `pos/engine.php` | Router POS (menu, koszyk, checkout, accept, settle, panic) | +| `tables/engine.php` | Router Stolik├│w + Waiter (plany sali, rachunki, transfery) | +| `courses/engine.php` | Router logistyki (dispatch, GPS, reconcile, payment lock, recall) | +| `kds/engine.php` | Router KDS (get_board, bump_order, recall_order) | +| `online/engine.php` | Router publicznej witryny (storefront, `delivery_zones`, `init_checkout`, `guest_checkout`, `track_order`) | +| `online_studio/engine.php` | Router Studio Online (director, composer, style presets, scene) | +| `online_studio/library_upload.php` | Multipart upload biblioteki asset├│w | +| `backoffice/api_menu_studio.php` | Router Studio menu (CRUD menu, ceny, modyfikatory, receptury) | +| `backoffice/api_visual_studio.php` | ÔÜá ORPHAN ÔÇö nieu┼╝ywany legacy uploader | +| `assets/engine.php` | **Single Source of Truth** dla asset├│w (m021): upload, CRUD, health scan | +| `settings/engine.php` | Router ustawie┼ä tenanta | + +#### Cart & Orders +| ┼Ücie┼╝ka | Opis | +|---------|------| +| `cart/CartEngine.php` | Klasa silnika koszyka (ceny, grosze, half/half) | +| `cart/calculate.php` | Endpoint kalkulacji koszyka | +| `orders/checkout.php` | Finalizacja zam├│wienia (kanoniczna / chroniona; nadal za `auth_guard.php`, nie jest publicznym checkoutem storefrontu) | +| `orders/accept.php` | ­ččí ORPHAN ÔÇö KDS ticket router (multi-station split); dubluje `pos/engine.php#accept_order` | +| `orders/edit.php` | ­ččí PLANNED ÔÇö edycja zam├│wienia + DeltaEngine (dla admin_hub) | +| `orders/estimate.php` | ­ččí PLANNED ÔÇö estymacja promised_time (dla scheduled orders) | +| `orders/panic.php` | ­ččí LEGACY DUPLICATE ÔÇö zast─ůpione przez `pos/engine.php#panic_mode` | +| `orders/sla_monitor.php` | ­ččí PLANNED ÔÇö aggregate SLA monitor (dla admin_hub + cron) | +| `orders/DeltaEngine.php` | Klasa wykrywaj─ůca r├│┼╝nice w liniach zam├│wienia | + +#### Warehouse +| ┼Ücie┼╝ka | Opis | +|---------|------| +| `warehouse/stock_list.php` | Lista stan├│w + filtry | +| `warehouse/warehouse_list.php` | S┼éownik magazyn├│w | +| `warehouse/receipt.php` | PZ ÔÇö przyj─Öcie (wywo┼éuje `core/PzEngine.php`) | +| `warehouse/internal_rw.php` | RW wewn─Ötrzny | +| `warehouse/batch_rw.php` | RW masowe | +| `warehouse/inventory.php` | INW (wywo┼éuje `core/InwEngine.php`) | +| `warehouse/correction.php` | KOR (wywo┼éuje `core/KorEngine.php`) | +| `warehouse/transfer.php` | MM (wywo┼éuje `core/MmEngine.php`) | +| `warehouse/add_item.php` | Dodanie pozycji do s┼éownika | +| `warehouse/approve.php` | Zatwierdzanie dokument├│w | +| `warehouse/avco_dict.php` | S┼éownik AVCO | +| `warehouse/documents_list.php` | Lista dokument├│w magazynowych | +| `warehouse/mapping.php` | Mapowanie surowc├│w | + +#### Delivery (standalone, koegzystuje z `courses/engine.php`) +| ┼Ücie┼╝ka | Opis | +|---------|------| +| `delivery/dispatch.php` | Standalone dispatch endpoint | +| `delivery/reconcile.php` | Standalone rozliczenie | + +#### Payments, Staff, Reports, Dashboard ÔÇö FAZA 3 (wi─Ökszo┼Ť─ç PLANNED) +| ┼Ücie┼╝ka | Status | +|---------|--------| +| `payments/settle.php` | ­ččí ORPHAN ÔÇö split-tender settlement, dubluje `pos/engine.php#settle_and_close` | +| `staff/clock.php` | ­ččí PLANNED ÔÇö clock-in/out (ClockEngine) | +| `staff/payroll.php` | ­ččí PLANNED ÔÇö payroll single user (PayrollEngine) | +| `dashboard/team_payroll.php` | ­ččí PLANNED ÔÇö team payroll (TeamPayrollEngine) | +| `reports/food_cost.php` | ­ččí PLANNED ÔÇö food cost + margin (FoodCostEngine) | + +#### Gateway / Integrations (m026ÔÇôm029) +| ┼Ücie┼╝ka | Opis | +|---------|------| +| `gateway/intake.php` | Zewn─Ötrzny punkt wej┼Ťcia (multi-key auth, rate limit, idempotency) | +| `integrations/inbound.php` | Callback handler dla 3rd-party POS / dostawc├│w (webhook inbound) | + +#### Utility +| ┼Ücie┼╝ka | Status | +|---------|--------| +| `system/generate_seq.php` | ­ččí UTILITY ÔÇö HTTP wrapper na `SequenceEngine` | +| `studio/generate_key.php` | ­ččí UTILITY ÔÇö HTTP wrapper na `AsciiKeyEngine` | +| `visual_composer/asset_upload.php` | Smarter uploader dla layer/hero (u┼╝ywany przez online_studio) | + +Wszystkie orphan/planned endpointy maj─ů w nag┼é├│wku komentarz `// STATUS: ÔÇŽ` wyja┼Ťniaj─ůcy docelowego konsumenta. + +--- + +### Core `/core/` + +#### Foundation +| Plik | Rola | +|------|------| +| `db_config.php` | Po┼é─ůczenie PDO Ôćĺ `$pdo` | +| `AuthEngine.php` | loginSystem, loginKiosk, getTargetModule | +| `AuthGuard.php` | Stateless JWT guard (V2) | +| `auth_guard.php` | Session + JWT guard (akceptuje oba ÔÇö u┼╝ywany w endpointach chronionych; NIE w `api/online/engine.php`) | +| `JwtProvider.php` | Generowanie / walidacja JWT (HS256) | +| `CredentialVault.php` | Transparent AEAD encryption dla wra┼╝liwych danych (m029) | +| `GatewayAuth.php` | Multi-key auth + rate limit + idempotency (m027) | + +#### Business engines +| Plik | Rola | +|------|------| +| `OrderStateMachine.php` | Transitions: `new Ôćĺ accepted Ôćĺ preparing Ôćĺ ready Ôćĺ in_delivery Ôćĺ completed / cancelled` | +| `OrderEventPublisher.php` | Transactional outbox dla event bus (m026) | +| `WebhookDispatcher.php` | Asynchroniczna dostawa webhook├│w (m026ÔÇôm027) | +| `PromisedTimeEngine.php` | Obliczanie promised_time (kuchnia + dojazd + bufor) | +| `SequenceEngine.php` | Atomowa generacja numer├│w dokument├│w (┬ž28) | +| `AsciiKeyEngine.php` | Transliteracja + normalizacja + collision probe (┬ž29) | + +#### Warehouse engines +| Plik | Rola | +|------|------| +| `PzEngine.php` | Przyj─Öcie + AVCO | +| `WzEngine.php` | Zu┼╝ycie surowc├│w po acceptance (waste + modyfikatory) | +| `InwEngine.php` | Inwentaryzacja | +| `KorEngine.php` | Korekta | +| `MmEngine.php` | Mi─Ödzymagazynowe | + +#### Payroll & staff +| Plik | Rola | +|------|------| +| `ClockEngine.php` | Clock-in/out, kalkulacja godzin | +| `PayrollEngine.php` | Payroll jednostkowy | +| `TeamPayrollEngine.php` | Payroll agregatowy | + +#### Visual & assets +| Plik | Rola | +|------|------| +| `AssetResolver.php` | Unified URL resolver (m021, m025 cleanup) | +| `SceneResolver.php` | Full dish scene contracts (m022) | +| `FoodCostEngine.php` | Food cost + margin per-channel | + +#### Integrations `/core/Integrations/` (m028) +| Plik | Rola | +|------|------| +| `AdapterRegistry.php` | Rejestr adapter├│w 3rd-party | +| `BaseAdapter.php` | Klasa bazowa | +| `IntegrationDispatcher.php` | Wysy┼éka do adapter├│w | +| `DotykackaAdapter.php` | Dotyka─Źka POS | +| `GastroSoftAdapter.php` | GastroSoft | +| `PapuAdapter.php` + `PapuClient.php` | Papu (delivery aggregator) | + +#### Frontend helpers `/core/js/` +| Plik | Rola | +|------|------| +| `api_client.js` | Bazowy `ApiClient` (fetch wrapper z Bearer tokenem) | +| `core_validator.js` | Walidatory wsp├│┼édzielone | +| `neon_pizza_engine.js` | Animacje / efekty wizualne | +| `scene_renderer.js` | Rendering scen na frontendzie | + +--- + +## 3. BAZA DANYCH & SKRYPTY + +### Migracje `/database/migrations/` + +| Nr | Plik | Co dodaje | +|----|------|-----------| +| 001 | `001_init_slicehub_pro_v2.sql` | Grand schema (wszystkie tabele, widoki, FK, indeksy) | +| 004 | `004_expand_search_aliases.sql` | sys_items: search_aliases, is_active, is_deleted + PL deklinacje | +| 006 | `006_studio_mission_control.sql` | sh_categories: VAT / sh_menu_items: PLU, dost─Öpno┼Ť─ç | +| 007 | `007_pos_engine_columns.sql` | sh_orders: druk paragonu, cart_json, NIP | +| 008 | `008_delivery_ecosystem.sql` | `sh_driver_locations` (GPS) | +| 009 | `009_delivery_state_machine.sql` | Stany delivery | +| 010 | `010_driver_action_type.sql` | Akcje kierowcy (pack_cold, check_idÔÇŽ) | +| 011 | `011_integration_logs.sql` | Logi integracji 3rd-party | +| 012 | `012_visual_layers.sql` | Warstwy wizualne (pierwsza iteracja) | +| 013 | `013_board_companions.sql` | Companions | +| 014 | `014_global_assets.sql` | Pierwsza iteracja globalnej biblioteki asset├│w | +| 015 | `015_normalize_three_drivers.sql` | Normalizacja r├│l kierowc├│w | +| 016 | `016_visual_compositor_upgrade.sql` | Hero photo + kalibracja; surface bg | +| 017 | `017_online_module_extensions.sql` | Rozszerzenia dla modu┼éu online | +| 019 | `019_layer_positioning.sql` | Pozycjonowanie warstw | +| 020 | `020_director_scenes.sql` | Sceny Directora | +| 021 | `021_unified_asset_library.sql` | **Single Source of Truth** ÔÇö `sh_assets` + `sh_asset_links` | +| 022 | `022_scene_kit.sql` | Scene Kit (contracts) | +| 023 | `023_scene_templates_content.sql` | Seedy szablon├│w scen | +| 024 | `024_modifier_visual_impact.sql` | Wp┼éyw modyfikator├│w na wizualny render | +| 025 | `025_drop_legacy_magic_dict.sql` | Czyszczenie legacy magic_* s┼éownik├│w | +| 026 | `026_event_system.sql` | Event bus + transactional outbox | +| 027 | `027_gateway_v2.sql` | Gateway V2 (multi-key, rate limit, idempotency) | +| 028 | `028_integration_deliveries.sql` | Integracje delivery/POS | +| 029 | `029_infrastructure_completion.sql` | Domkni─Öcie infrastruktury (vault, webhook retryÔÇŽ) | +| 030 | `030_scene_harmony_cache.sql` | Cache harmonii sceny | +| 031 | `031_baked_variants.sql` | "Upieczone" warianty wizualne | +| 032 | `032_asset_display_name.sql` | `sh_assets.display_name` | +| **ÔÜá 032** | `032_asset_library_organizer.sql` | **KOLIZJA NUMERU** ÔÇö do przenumerowania na 033 | + +> Luki 002/003/005/018 ÔÇö dawne seedy/eksperymenty przeniesione do `seed_demo_all.php` lub `_archive_*.sql`. +> Szczeg├│┼éy schematu Ôćĺ [`_docs/04_BAZA_DANYCH.md`](04_BAZA_DANYCH.md) + +### Skrypty `/scripts/` + +| Plik | Co robi | Uruchomienie | +|------|---------|--------------| +| `setup_database.php` | Migracje 006/007/008 (bez danych, idempotentny) | Przegl─ůdarka | +| `seed_demo_all.php` | Unified Demo Seed ÔÇö kompletne dane testowe dla CA┼üEGO systemu | Przegl─ůdarka / CLI | +| `seed_ultimate_delivery.php` | Delivery Ecosystem Seed ÔÇö kierowcy, zam├│wienia delivery (paid/unpaid), GPS | Przegl─ůdarka | + +### Procedura czystej instalacji + +``` +1. phpMyAdmin Ôćĺ CREATE DATABASE slicehub_pro_v2 (utf8mb4_unicode_ci) +2. phpMyAdmin Ôćĺ Import Ôćĺ database/migrations/001_init_slicehub_pro_v2.sql +3. Kolejno importuj migracje 004 Ôćĺ 032 (zgodnie z numeracj─ů) +4. Przegl─ůdarka Ôćĺ http://localhost/slicehub/scripts/seed_demo_all.php +``` + +--- + +## 4. LEGACY ÔÇö Strefa kwarantanny + +`/_KOPALNIA_WIEDZY_LEGACY/` ÔÇö Stary kod (60+ plik├│w PHP/HTML), w tym przeniesiony tam modu┼é `pos_kelner_STARY/` (dawniej `pos(kelner)/`). + +`/_archive/` ÔÇö Niezintegrowane HTML-e magazynowe (manager_in/pz/rw, settings_*). + +**ZASADY DLA AI:** +1. Oba foldery = **STRICTLY READ-ONLY** +2. Zakaz edycji, zakaz linkowania do nowego UI +3. S┼éu┼╝─ů wy┼é─ůcznie jako encyklopedia biznesowa ÔÇö czytaj, zrozum logik─Ö, napisz NOWY kod w folderze produkcyjnym + +--- + +## 5. DOKUMENTACJA + +| Plik | Zawarto┼Ť─ç | +|------|-----------| +| `_docs/START_TUTAJ.md` | **Punkt wej┼Ťcia** ÔÇö od tego pliku zaczynasz czytanie docs | +| `_docs/00_PAMIEC_SYSTEMU.md` | **NORTH STAR** ÔÇö master reference, 7 nienaruszalnych praw | +| `_docs/01_KONSTYTUCJA.md` | Konstytucja projektu (cele, filozofia) | +| `_docs/02_ARCHITEKTURA.md` | Ten plik ÔÇö mapa systemu | +| `_docs/03_MAPA_KOPALNI.md` | Mapa _KOPALNIA_WIEDZY_LEGACY | +| `_docs/04_BAZA_DANYCH.md` | Schemat bazy ÔÇö tabele, relacje, konwencje | +| `_docs/05_INSTRUKCJA_FOTO_UPLOAD.md` | Limity uploadu, walidacja, brief fotograficzny | +| `_docs/07_INTERACTION_CONTRACT.md` | Kontrakt interakcji frontend Ôćö backend | +| `_docs/08_ORDER_STATUS_DICTIONARY.md` | Kanoniczny s┼éownik status├│w zam├│wienia | +| `_docs/09_EVENT_SYSTEM.md` | Event bus (m026) | +| `_docs/10_GATEWAY_API.md` | Gateway V2 (m027) | +| `_docs/11_WEBHOOK_DISPATCHER.md` | Webhook dispatcher | +| `_docs/12_INTEGRATION_ADAPTERS.md` | Adaptery 3rd-party (m028) | +| `_docs/13_SETTINGS_PANEL.md` | Panel Settings | +| `_docs/14_INBOUND_CALLBACKS.md` | Callbacki przychodz─ůce | +| `_docs/15_KIERUNEK_ONLINE.md` | Kierunek rozwoju modu┼éu Online | +| `_docs/ustalenia.md` | Ustalenia projektowe (roboczy) | +| `_docs/ARCHIWUM/README.md` | Zasady archiwum dokumentacji | +| `database/README.md` | Quick start ÔÇö instalacja / reset / aktualizacja bazy | diff --git a/_docs/04_BAZA_DANYCH.md b/_docs/04_BAZA_DANYCH.md index e33069e..da2e6cb 100644 --- a/_docs/04_BAZA_DANYCH.md +++ b/_docs/04_BAZA_DANYCH.md @@ -1,1024 +1,212 @@ --- phpMyAdmin SQL Dump --- version 5.2.1 --- https://www.phpmyadmin.net/ --- --- Host: 127.0.0.1 --- Generation Time: Apr 10, 2026 at 10:35 PM --- Wersja serwera: 10.4.32-MariaDB --- Wersja PHP: 8.2.12 - -SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; -START TRANSACTION; -SET time_zone = "+00:00"; - - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8mb4 */; - --- --- Database: `slicehub_pro_v2` --- - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_categories` --- - -CREATE TABLE `sh_categories` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `name` varchar(100) NOT NULL, - `ascii_key` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `is_menu` tinyint(1) NOT NULL DEFAULT 1, - `display_order` int(11) NOT NULL DEFAULT 0, - `is_active` tinyint(1) NOT NULL DEFAULT 1, - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_categories` --- - -INSERT INTO `sh_categories` (`id`, `tenant_id`, `name`, `ascii_key`, `is_menu`, `display_order`, `is_active`, `is_deleted`, `updated_at`) VALUES -(1, 1, 'Pizze Testowe', 'CAT_PIZZE', 1, 1, 1, 0, '2026-04-06 20:23:59'), -(2, 1, 'ad', '', 1, 0, 1, 0, '2026-04-07 22:01:20'), -(3, 1, 'weg', '', 1, 0, 1, 0, '2026-04-07 22:03:37'), -(4, 1, 'asfasf', '', 1, 0, 1, 0, '2026-04-07 22:05:56'), -(5, 1, 'asf', '', 1, 0, 1, 0, '2026-04-07 22:40:45'), -(6, 1, 'adfs', '', 1, 0, 1, 0, '2026-04-07 23:03:30'), -(7, 1, 'BURGERY', '', 1, 0, 1, 0, '2026-04-10 20:30:42'); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_item_modifiers` --- - -CREATE TABLE `sh_item_modifiers` ( - `item_id` int(11) NOT NULL, - `group_id` int(11) NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_item_modifiers` --- - -INSERT INTO `sh_item_modifiers` (`item_id`, `group_id`) VALUES -(1, 11), -(1, 12), -(2, 12), -(3, 12); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_menu_items` --- - -CREATE TABLE `sh_menu_items` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `category_id` int(11) NOT NULL, - `ascii_key` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `barcode_ean` varchar(50) DEFAULT NULL, - `parent_sku` varchar(100) DEFAULT NULL, - `name` varchar(150) NOT NULL, - `type` enum('standard','half_half','modifier') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'standard', - `plu_code` varchar(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, - `printer_group` varchar(50) CHARACTER SET ascii COLLATE ascii_bin DEFAULT 'KITCHEN_1', - `display_order` int(11) NOT NULL DEFAULT 0, - `available_days` varchar(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT '1,2,3,4,5,6,7', - `available_start` time DEFAULT NULL, - `available_end` time DEFAULT NULL, - `badge_type` enum('none','new','promo','bestseller','hot') CHARACTER SET ascii COLLATE ascii_bin DEFAULT 'none', - `is_secret` tinyint(1) NOT NULL DEFAULT 0, - `stock_count` int(11) NOT NULL DEFAULT -1, - `vat_rate_dine_in` decimal(5,2) NOT NULL DEFAULT 8.00, - `vat_rate_takeaway` decimal(5,2) NOT NULL DEFAULT 8.00, - `is_active` tinyint(1) NOT NULL DEFAULT 1, - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), - `is_locked_by_hq` tinyint(1) NOT NULL DEFAULT 0, - `publication_status` enum('Draft','Live','Archived') NOT NULL DEFAULT 'Draft', - `valid_from` datetime DEFAULT NULL, - `valid_to` datetime DEFAULT NULL, - `description` text DEFAULT NULL, - `image_url` varchar(255) DEFAULT NULL, - `marketing_tags` varchar(255) DEFAULT NULL, - `allergens_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`allergens_json`)), - `kds_station_id` varchar(50) NOT NULL DEFAULT 'NONE' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_menu_items` --- - -INSERT INTO `sh_menu_items` (`id`, `tenant_id`, `category_id`, `ascii_key`, `barcode_ean`, `parent_sku`, `name`, `type`, `plu_code`, `printer_group`, `display_order`, `available_days`, `available_start`, `available_end`, `badge_type`, `is_secret`, `stock_count`, `vat_rate_dine_in`, `vat_rate_takeaway`, `is_active`, `is_deleted`, `updated_at`, `is_locked_by_hq`, `publication_status`, `valid_from`, `valid_to`, `description`, `image_url`, `marketing_tags`, `allergens_json`, `kds_station_id`) VALUES -(1, 1, 1, 'ITM_MARGHERITA', NULL, NULL, 'Margherita V1', 'standard', NULL, 'KITCHEN_1', 0, '1,2,3,4,5,6,7', NULL, NULL, 'new', 0, -1, 8.00, 8.00, 1, 0, '2026-04-09 02:18:51', 0, 'Draft', '2026-01-11 18:15:00', '0226-01-15 20:00:00', '', '', '', '[]', 'NONE'), -(2, 1, 1, 'PIZZA_DI_PARMA', NULL, NULL, 'Pizza di parma', 'standard', NULL, 'KITCHEN_1', 0, '1,2,3,4,5,6,7', NULL, NULL, 'none', 0, -1, 8.00, 8.00, 1, 0, '2026-04-09 02:14:19', 0, 'Draft', '2026-01-11 18:15:00', '0226-01-15 20:00:00', 'najlepsza pizza na swiecie', '', 'asd', '[]', 'NONE'), -(3, 1, 1, 'DIAWOLA', NULL, NULL, 'diawola', 'standard', NULL, 'KITCHEN_1', 0, '1,2,3,4,5,6,7', NULL, NULL, 'none', 0, -1, 8.00, 8.00, 1, 0, '2026-04-10 19:58:41', 0, 'Live', NULL, NULL, '', '', '', '[\"Gluten\",\"Laktoza\",\"Orzechy\",\"Skorupiaki\",\"Jaja\",\"Ryby\",\"Soja\",\"Seler\",\"Gorczyca\",\"Sezam\",\"Mi\\u0119czaki\"]', 'BAR'); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_modifiers` --- - -CREATE TABLE `sh_modifiers` ( - `id` int(11) NOT NULL, - `group_id` int(11) NOT NULL, - `name` varchar(100) NOT NULL, - `ascii_key` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `is_active` tinyint(1) NOT NULL DEFAULT 1, - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), - `action_type` enum('NONE','ADD','REMOVE') NOT NULL DEFAULT 'NONE', - `linked_warehouse_sku` varchar(50) DEFAULT NULL, - `linked_quantity` decimal(10,3) NOT NULL DEFAULT 0.000, - `is_default` tinyint(1) NOT NULL DEFAULT 0 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_modifiers` --- - -INSERT INTO `sh_modifiers` (`id`, `group_id`, `name`, `ascii_key`, `is_active`, `is_deleted`, `updated_at`, `action_type`, `linked_warehouse_sku`, `linked_quantity`, `is_default`) VALUES -(1, 1, 'sos czosnkowy', 'OPT_SOS_CZOSNKOWY', 1, 1, '2026-04-07 18:26:01', 'NONE', NULL, 0.000, 0), -(2, 1, 'sos pomidorowy', 'OPT_SOS_POMIDOROWY', 1, 1, '2026-04-07 18:26:01', 'NONE', NULL, 0.000, 0), -(3, 1, 'sos czosnkowy', 'OPT_SOS_CZOSNKOWY', 1, 0, '2026-04-07 18:26:01', 'NONE', NULL, 0.000, 0), -(4, 1, 'sos pomidorowy', 'OPT_SOS_POMIDOROWY', 1, 0, '2026-04-07 18:26:01', 'NONE', NULL, 0.000, 0), -(5, 2, 'czosnkowy', 'OPT_CZOSNKOWY', 1, 0, '2026-04-07 18:29:18', 'NONE', NULL, 0.000, 0), -(6, 3, 'asdaa', 'OPT_ASDAA', 1, 0, '2026-04-07 18:43:58', 'NONE', NULL, 0.000, 0), -(7, 4, 'asdfv', 'OPT_ASDFV', 1, 1, '2026-04-07 18:45:12', 'NONE', NULL, 0.000, 0), -(8, 4, 'asdfv', 'OPT_ASDFV', 1, 0, '2026-04-07 18:45:12', 'NONE', NULL, 0.000, 0), -(9, 5, 'Czosnkowy', 'OPT_CZOSNKOWY', 1, 0, '2026-04-07 18:56:15', 'NONE', NULL, 0.000, 0), -(10, 5, 'Pomidorowy', 'OPT_POMIDOROWY', 1, 0, '2026-04-07 18:56:15', 'NONE', NULL, 0.000, 0), -(11, 6, 'asd', 'OPT_ASD', 1, 1, '2026-04-07 18:57:16', 'NONE', NULL, 0.000, 0), -(12, 6, 'asd', 'OPT_ASD', 1, 0, '2026-04-07 18:57:16', 'NONE', NULL, 0.000, 0), -(13, 7, 'fd', 'OPT_FD', 1, 0, '2026-04-07 22:24:23', 'NONE', NULL, 0.000, 0), -(14, 8, 'asdfsaf', 'OPT_ASDFSAF', 1, 0, '2026-04-07 22:41:06', 'NONE', NULL, 0.000, 0), -(15, 9, 'asfdgdsgds', 'OPT_ASFDGDSGDS', 1, 0, '2026-04-07 22:59:52', 'NONE', NULL, 0.000, 0), -(16, 10, 'sfa', 'OPT_SFA', 1, 0, '2026-04-07 23:03:45', 'NONE', NULL, 0.000, 0), -(17, 11, 'asdasfsa', 'OPT_ASDASFSA', 1, 1, '2026-04-07 23:12:27', 'NONE', NULL, 0.000, 0), -(18, 11, 'asdasfsa', 'OPT_ASDASFSA', 1, 0, '2026-04-07 23:12:27', 'ADD', 'SKU_SER_MOZZ', 2.000, 0), -(19, 12, 'pomidorowy', 'OPT_POMIDOROWY', 1, 0, '2026-04-08 16:59:29', 'NONE', NULL, 0.000, 0), -(20, 12, 'majonezowy', 'OPT_MAJONEZOWY', 1, 0, '2026-04-08 16:59:29', 'NONE', NULL, 0.000, 0), -(21, 12, 'ser', 'OPT_SER', 1, 1, '2026-04-09 02:18:35', 'NONE', NULL, 0.000, 0), -(22, 12, 'sesgrrsgsrhrhrehreh', 'OPT_SER', 1, 0, '2026-04-10 19:59:26', 'NONE', NULL, 0.000, 0); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_modifier_groups` --- - -CREATE TABLE `sh_modifier_groups` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `name` varchar(100) NOT NULL, - `min_selection` int(11) NOT NULL DEFAULT 0, - `max_selection` int(11) NOT NULL DEFAULT 10, - `is_active` tinyint(1) NOT NULL DEFAULT 1, - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), - `ascii_key` varchar(50) DEFAULT NULL, - `allow_multi_qty` tinyint(1) NOT NULL DEFAULT 0, - `is_locked_by_hq` tinyint(1) NOT NULL DEFAULT 0, - `publication_status` enum('Draft','Live','Archived') NOT NULL DEFAULT 'Draft', - `valid_from` datetime DEFAULT NULL, - `valid_to` datetime DEFAULT NULL, - `free_limit` int(11) NOT NULL DEFAULT 0 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_modifier_groups` --- - -INSERT INTO `sh_modifier_groups` (`id`, `tenant_id`, `name`, `min_selection`, `max_selection`, `is_active`, `is_deleted`, `updated_at`, `ascii_key`, `allow_multi_qty`, `is_locked_by_hq`, `publication_status`, `valid_from`, `valid_to`, `free_limit`) VALUES -(1, 1, 'sosy', 0, 10, 1, 0, '2026-04-07 18:25:52', NULL, 0, 0, 'Draft', NULL, NULL, 0), -(2, 1, 'sosy', 0, 1, 1, 0, '2026-04-07 18:29:18', NULL, 0, 0, 'Draft', NULL, NULL, 0), -(3, 1, 'asd', 0, 1, 1, 0, '2026-04-07 18:43:58', NULL, 0, 0, 'Draft', NULL, NULL, 0), -(4, 1, 'acd', 0, 1, 1, 0, '2026-04-07 18:45:09', NULL, 0, 0, 'Draft', NULL, NULL, 0), -(5, 1, 'Sosy', 0, 20, 1, 0, '2026-04-07 18:56:15', NULL, 0, 0, 'Draft', NULL, NULL, 0), -(6, 1, 'Sosy', 0, 1, 1, 0, '2026-04-07 18:57:09', NULL, 0, 0, 'Draft', NULL, NULL, 0), -(7, 1, 'Sosy', 0, 1, 1, 0, '2026-04-07 22:24:23', 'GRP_SOSY', 0, 0, 'Draft', NULL, NULL, 0), -(8, 1, 'asdasd', 0, 1, 1, 0, '2026-04-07 22:41:06', 'GRP_ASDASD', 0, 0, 'Draft', NULL, NULL, 0), -(9, 1, 'asdasd', 0, 1, 1, 0, '2026-04-07 22:59:52', 'GRP_ASDASD', 0, 0, 'Draft', NULL, NULL, 0), -(10, 1, 'adssaf', 0, 1, 1, 0, '2026-04-07 23:03:45', 'GRP_ADSSAF', 0, 0, 'Draft', NULL, NULL, 0), -(11, 1, 'asffsdg', 0, 1, 1, 0, '2026-04-07 23:12:17', 'GRP_ASFFSDG', 0, 0, 'Draft', NULL, NULL, 0), -(12, 1, 'SOSY test', 0, 5, 1, 0, '2026-04-08 16:59:29', 'GRP_SOSY_TEST', 1, 0, 'Draft', NULL, NULL, 0); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_orders` --- - -CREATE TABLE `sh_orders` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `uuid` varchar(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `order_number` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `source` enum('local','online','kiosk','delivery_aggregator') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'local', - `type` enum('dine_in','takeaway','delivery') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'dine_in', - `status` enum('new','pending','preparing','ready','in_delivery','completed','cancelled') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'new', - `payment_method` enum('cash','card','online','mixed') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'cash', - `payment_status` enum('unpaid','paid','refunded') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'unpaid', - `total_price` decimal(10,2) NOT NULL DEFAULT 0.00, - `table_id` int(11) DEFAULT NULL, - `created_by` int(11) DEFAULT NULL, - `customer_name` varchar(150) DEFAULT NULL, - `customer_phone` varchar(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, - `address` text DEFAULT NULL, - `nip` varchar(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, - `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_order_items` --- - -CREATE TABLE `sh_order_items` ( - `id` int(11) NOT NULL, - `order_id` int(11) NOT NULL, - `menu_item_sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `snapshot_name` varchar(150) NOT NULL, - `quantity` decimal(10,2) NOT NULL DEFAULT 1.00, - `unit_price` decimal(10,2) NOT NULL DEFAULT 0.00, - `vat_rate` decimal(5,2) NOT NULL DEFAULT 8.00, - `course_id` varchar(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_order_item_modifiers` --- - -CREATE TABLE `sh_order_item_modifiers` ( - `id` int(11) NOT NULL, - `order_item_id` int(11) NOT NULL, - `modifier_type` enum('ADDED','REMOVED') CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `modifier_sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `snapshot_name` varchar(150) NOT NULL, - `price_change` decimal(10,2) NOT NULL DEFAULT 0.00 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_price_tiers` --- - -CREATE TABLE `sh_price_tiers` ( - `id` int(11) NOT NULL, - `target_type` enum('ITEM','MODIFIER') NOT NULL, - `target_sku` varchar(50) NOT NULL, - `channel` enum('POS','Takeaway','Delivery') NOT NULL, - `price` decimal(10,2) NOT NULL DEFAULT 0.00 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_price_tiers` --- - -INSERT INTO `sh_price_tiers` (`id`, `target_type`, `target_sku`, `channel`, `price`) VALUES -(1, 'MODIFIER', 'OPT_FD', 'POS', 1.00), -(2, 'MODIFIER', 'OPT_FD', 'Takeaway', 1.00), -(3, 'MODIFIER', 'OPT_FD', 'Delivery', 1.00), -(4, 'MODIFIER', 'OPT_ASDFSAF', 'POS', 0.00), -(5, 'MODIFIER', 'OPT_ASDFSAF', 'Takeaway', 0.00), -(6, 'MODIFIER', 'OPT_ASDFSAF', 'Delivery', 0.00), -(7, 'ITEM', 'PIZZA_DI_PARMA', 'POS', 90.00), -(8, 'ITEM', 'PIZZA_DI_PARMA', 'Takeaway', 34.00), -(9, 'ITEM', 'PIZZA_DI_PARMA', 'Delivery', 34.00), -(10, 'MODIFIER', 'OPT_ASFDGDSGDS', 'POS', 1.00), -(11, 'MODIFIER', 'OPT_ASFDGDSGDS', 'Takeaway', 1.00), -(12, 'MODIFIER', 'OPT_ASFDGDSGDS', 'Delivery', 2.00), -(13, 'MODIFIER', 'OPT_SFA', 'POS', 10.00), -(14, 'MODIFIER', 'OPT_SFA', 'Takeaway', 0.00), -(15, 'MODIFIER', 'OPT_SFA', 'Delivery', 0.00), -(16, 'MODIFIER', 'OPT_ASDASFSA', 'POS', 1.00), -(17, 'MODIFIER', 'OPT_ASDASFSA', 'Takeaway', 0.00), -(18, 'MODIFIER', 'OPT_ASDASFSA', 'Delivery', 0.00), -(22, 'ITEM', 'ITM_MARGHERITA', 'POS', 200.00), -(36, 'MODIFIER', 'OPT_POMIDOROWY', 'POS', 9.00), -(37, 'MODIFIER', 'OPT_POMIDOROWY', 'Takeaway', 9.00), -(38, 'MODIFIER', 'OPT_POMIDOROWY', 'Delivery', 9.00), -(39, 'MODIFIER', 'OPT_MAJONEZOWY', 'POS', 9.00), -(40, 'MODIFIER', 'OPT_MAJONEZOWY', 'Takeaway', 9.00), -(41, 'MODIFIER', 'OPT_MAJONEZOWY', 'Delivery', 9.00), -(46, 'ITEM', 'ITM_MARGHERITA', 'Takeaway', 200.00), -(47, 'ITEM', 'ITM_MARGHERITA', 'Delivery', 200.00), -(72, 'MODIFIER', 'OPT_SER', 'POS', 2.00), -(73, 'MODIFIER', 'OPT_SER', 'Takeaway', 0.00), -(74, 'MODIFIER', 'OPT_SER', 'Delivery', 0.00), -(87, 'ITEM', 'DIAWOLA', 'POS', 35.00), -(88, 'ITEM', 'DIAWOLA', 'Takeaway', 35.00), -(89, 'ITEM', 'DIAWOLA', 'Delivery', 35.00); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_recipes` --- - -CREATE TABLE `sh_recipes` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `menu_item_sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `warehouse_sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `quantity_base` decimal(10,3) NOT NULL, - `waste_percent` decimal(5,2) NOT NULL DEFAULT 0.00, - `is_packaging` tinyint(1) NOT NULL DEFAULT 0, - `valid_from` date DEFAULT NULL, - `valid_to` date DEFAULT NULL, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_recipes` --- - -INSERT INTO `sh_recipes` (`id`, `tenant_id`, `menu_item_sku`, `warehouse_sku`, `quantity_base`, `waste_percent`, `is_packaging`, `valid_from`, `valid_to`, `updated_at`) VALUES -(13, 1, 'ITM_MARGHERITA', 'SKU_MAKA_00', 2.000, 0.00, 0, NULL, NULL, '2026-04-07 00:34:48'), -(14, 1, 'ITM_MARGHERITA', 'SKU_SER_MOZZ', 1.000, 0.00, 0, NULL, NULL, '2026-04-07 00:34:48'), -(15, 1, 'ITM_MARGHERITA', 'SKU_SOS_MUTTI', 2.000, 0.00, 0, NULL, NULL, '2026-04-07 00:34:48'); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_recipe_steps` --- - -CREATE TABLE `sh_recipe_steps` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `menu_item_sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `step_order` int(11) NOT NULL DEFAULT 1, - `instruction` text NOT NULL, - `ccp_temp_min` decimal(5,2) DEFAULT NULL, - `ccp_temp_max` decimal(5,2) DEFAULT NULL, - `prep_time_seconds` int(11) NOT NULL DEFAULT 0 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_tables` --- - -CREATE TABLE `sh_tables` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `table_number` varchar(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `qr_key` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `status` enum('free','occupied','dirty','reserved') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'free', - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_tenants` --- - -CREATE TABLE `sh_tenants` ( - `id` int(11) NOT NULL, - `nip` varchar(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, - `name` varchar(100) NOT NULL, - `primary_color` varchar(7) CHARACTER SET ascii COLLATE ascii_bin DEFAULT '#e63946', - `is_active` tinyint(1) NOT NULL DEFAULT 1, - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sh_tenants` --- - -INSERT INTO `sh_tenants` (`id`, `nip`, `name`, `primary_color`, `is_active`, `is_deleted`, `created_at`, `updated_at`) VALUES -(1, NULL, 'Centrala SliceHub', '#e63946', 1, 0, '2026-04-06 20:23:59', '2026-04-06 20:23:59'); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_users` --- - -CREATE TABLE `sh_users` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `username` varchar(50) NOT NULL, - `first_name` varchar(50) DEFAULT NULL, - `last_name` varchar(50) DEFAULT NULL, - `password_hash` varchar(255) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, - `pin` varchar(4) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, - `role` enum('admin','manager','waiter','kitchen','driver') CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'waiter', - `is_active` tinyint(1) NOT NULL DEFAULT 1, - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sh_work_sessions` --- - -CREATE TABLE `sh_work_sessions` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `user_id` int(11) NOT NULL, - `start_time` datetime NOT NULL, - `end_time` datetime DEFAULT NULL, - `total_time` decimal(10,2) DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `sys_items` --- - -CREATE TABLE `sys_items` ( - `sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `tenant_id` int(11) NOT NULL, - `name` varchar(150) NOT NULL, - `base_unit` varchar(10) NOT NULL DEFAULT 'szt', - `vat_rate_purchase` decimal(5,2) NOT NULL DEFAULT 23.00, - `is_active` tinyint(1) NOT NULL DEFAULT 1, - `is_deleted` tinyint(1) NOT NULL DEFAULT 0, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), - `search_aliases` varchar(255) DEFAULT NULL COMMENT 'Odmiany po przecinku, np. m─ůk─ů, m─ůce, m─ůki', - `allergens_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`allergens_json`)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `sys_items` --- - -INSERT INTO `sys_items` (`sku`, `tenant_id`, `name`, `base_unit`, `vat_rate_purchase`, `is_active`, `is_deleted`, `updated_at`, `search_aliases`, `allergens_json`) VALUES -('SKU_KARTON_32', 1, 'Karton do Pizzy 32cm', 'szt', 23.00, 1, 0, '2026-04-06 21:38:06', NULL, NULL), -('SKU_MAKA_00', 1, 'M─ůka Typ 00', 'kg', 0.00, 1, 0, '2026-04-06 22:57:46', 'm─ůka,m─ůk─ů,m─ůce,m─ůki', NULL), -('SKU_SER_MOZZ', 1, 'Ser Mozzarella', 'kg', 5.00, 1, 0, '2026-04-06 22:57:46', 'ser,serem,sera,serze,mozzarell─ů,mozzarelli', NULL), -('SKU_SOS_MUTTI', 1, 'Sos Pomidorowy Mutti', 'l', 8.00, 1, 0, '2026-04-06 22:57:46', 'sos,sosem,sosu,pomidorowym', NULL); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `wh_inventory_docs` --- - -CREATE TABLE `wh_inventory_docs` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `doc_number` varchar(50) NOT NULL, - `doc_type` enum('PZ','WZ','RW','INW') NOT NULL, - `status` enum('DRAFT','COMPLETED','CANCELLED') NOT NULL DEFAULT 'DRAFT', - `created_by` int(11) DEFAULT NULL, - `notes` text DEFAULT NULL, - `created_at` timestamp NOT NULL DEFAULT current_timestamp(), - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `wh_inventory_docs` --- - -INSERT INTO `wh_inventory_docs` (`id`, `tenant_id`, `doc_number`, `doc_type`, `status`, `created_by`, `notes`, `created_at`, `updated_at`) VALUES -(3, 1, 'PZ/20260409040103', 'PZ', 'COMPLETED', NULL, NULL, '2026-04-09 02:01:03', '2026-04-09 02:01:03'), -(4, 1, 'PZ/20260409040126', 'PZ', 'COMPLETED', NULL, NULL, '2026-04-09 02:01:26', '2026-04-09 02:01:26'), -(5, 1, 'PZ/20260409041528', 'PZ', 'COMPLETED', NULL, NULL, '2026-04-09 02:15:28', '2026-04-09 02:15:28'), -(6, 1, 'PZ/20260409184941', 'PZ', 'COMPLETED', NULL, NULL, '2026-04-09 16:49:41', '2026-04-09 16:49:41'), -(7, 1, 'PZ/20260409190350', 'PZ', 'COMPLETED', NULL, NULL, '2026-04-09 17:03:50', '2026-04-09 17:03:50'), -(8, 1, 'RW/20260409191212', 'RW', 'COMPLETED', NULL, 'Zniszczenie', '2026-04-09 17:12:12', '2026-04-09 17:12:12'), -(9, 1, 'RW/20260409191246', 'RW', 'COMPLETED', NULL, 'Przeterminowanie', '2026-04-09 17:12:46', '2026-04-09 17:12:46'), -(10, 1, 'RW/20260409191324', 'RW', 'COMPLETED', NULL, 'Przeterminowanie', '2026-04-09 17:13:24', '2026-04-09 17:13:24'), -(11, 1, 'PZ/20260410204900', 'PZ', 'COMPLETED', NULL, NULL, '2026-04-10 18:49:00', '2026-04-10 18:49:00'); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `wh_inventory_doc_items` --- - -CREATE TABLE `wh_inventory_doc_items` ( - `id` int(11) NOT NULL, - `doc_id` int(11) NOT NULL, - `sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `qty` decimal(10,3) NOT NULL, - `unit_price` decimal(10,2) NOT NULL DEFAULT 0.00, - `total_price` decimal(10,2) NOT NULL DEFAULT 0.00 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `wh_inventory_doc_items` --- - -INSERT INTO `wh_inventory_doc_items` (`id`, `doc_id`, `sku`, `qty`, `unit_price`, `total_price`) VALUES -(1, 3, 'SKU_SER_MOZZ', 10.000, 5.00, 50.00), -(2, 4, 'SKU_SER_MOZZ', 5.000, 7.00, 35.00), -(3, 5, 'SKU_SER_MOZZ', 5.000, 3.00, 15.00), -(4, 6, 'SKU_SER_MOZZ', 10.000, 100.00, 1000.00), -(5, 7, 'SKU_KARTON_32', 100.000, 2.00, 200.00), -(6, 8, 'SKU_MAKA_00', 100.000, 0.00, 0.00), -(7, 9, 'SKU_SER_MOZZ', 10.000, 36.67, 366.70), -(8, 10, 'SKU_SER_MOZZ', 5.000, 36.67, 183.35), -(9, 11, 'SKU_MAKA_00', 3.000, 1.00, 3.00); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `wh_stock` --- - -CREATE TABLE `wh_stock` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `quantity` decimal(10,3) NOT NULL DEFAULT 0.000, - `unit_net_cost` decimal(10,2) NOT NULL DEFAULT 0.00, - `current_avco_price` decimal(10,2) NOT NULL DEFAULT 0.00, - `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `wh_stock` --- - -INSERT INTO `wh_stock` (`id`, `tenant_id`, `sku`, `quantity`, `unit_net_cost`, `current_avco_price`, `updated_at`) VALUES -(1, 1, 'SKU_SER_MOZZ', 15.000, 100.00, 36.67, '2026-04-09 17:13:24'), -(5, 1, 'SKU_KARTON_32', 100.000, 2.00, 2.00, '2026-04-09 17:03:50'), -(6, 1, 'SKU_MAKA_00', -97.000, 1.00, 1.00, '2026-04-10 18:49:00'); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `wh_stock_logs` --- - -CREATE TABLE `wh_stock_logs` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `warehouse_id` int(11) NOT NULL DEFAULT 1, - `sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `change_qty` decimal(10,3) NOT NULL, - `after_qty` decimal(10,3) NOT NULL, - `document_type` enum('PZ','WZ','RW','INW','MM','MANUAL','POS_SALE') NOT NULL, - `document_id` int(11) DEFAULT NULL, - `created_by` int(11) DEFAULT NULL, - `created_at` timestamp NOT NULL DEFAULT current_timestamp() -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Dumping data for table `wh_stock_logs` --- - -INSERT INTO `wh_stock_logs` (`id`, `tenant_id`, `warehouse_id`, `sku`, `change_qty`, `after_qty`, `document_type`, `document_id`, `created_by`, `created_at`) VALUES -(1, 1, 1, 'SKU_SER_MOZZ', 10.000, 10.000, 'PZ', 3, NULL, '2026-04-09 02:01:03'), -(2, 1, 1, 'SKU_SER_MOZZ', 5.000, 15.000, 'PZ', 4, NULL, '2026-04-09 02:01:26'), -(3, 1, 1, 'SKU_SER_MOZZ', 5.000, 20.000, 'PZ', 5, NULL, '2026-04-09 02:15:28'), -(4, 1, 1, 'SKU_SER_MOZZ', 10.000, 30.000, 'PZ', 6, NULL, '2026-04-09 16:49:41'), -(5, 1, 1, 'SKU_KARTON_32', 100.000, 100.000, 'PZ', 7, NULL, '2026-04-09 17:03:50'), -(6, 1, 1, 'SKU_MAKA_00', -100.000, -100.000, 'RW', 8, NULL, '2026-04-09 17:12:12'), -(7, 1, 1, 'SKU_SER_MOZZ', -10.000, 20.000, 'RW', 9, NULL, '2026-04-09 17:12:46'), -(8, 1, 1, 'SKU_SER_MOZZ', -5.000, 15.000, 'RW', 10, NULL, '2026-04-09 17:13:24'), -(9, 1, 1, 'SKU_MAKA_00', 3.000, -97.000, 'PZ', 11, NULL, '2026-04-10 18:49:00'); - --- -------------------------------------------------------- - --- --- Struktura tabeli dla tabeli `wh_uom_conversions` --- - -CREATE TABLE `wh_uom_conversions` ( - `id` int(11) NOT NULL, - `tenant_id` int(11) NOT NULL, - `sku` varchar(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `purchase_unit_name` varchar(50) NOT NULL, - `multiplier` decimal(10,3) NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Indeksy dla zrzut├│w tabel --- - --- --- Indeksy dla tabeli `sh_categories` --- -ALTER TABLE `sh_categories` - ADD PRIMARY KEY (`id`), - ADD KEY `tenant_id` (`tenant_id`); - --- --- Indeksy dla tabeli `sh_item_modifiers` --- -ALTER TABLE `sh_item_modifiers` - ADD PRIMARY KEY (`item_id`,`group_id`), - ADD KEY `fk_item_mod_group` (`group_id`); - --- --- Indeksy dla tabeli `sh_menu_items` --- -ALTER TABLE `sh_menu_items` - ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `uq_menu_ascii` (`ascii_key`), - ADD UNIQUE KEY `idx_tenant_ean` (`tenant_id`,`barcode_ean`), - ADD KEY `tenant_id` (`tenant_id`), - ADD KEY `category_id` (`category_id`), - ADD KEY `idx_parent_sku` (`tenant_id`,`parent_sku`); - --- --- Indeksy dla tabeli `sh_modifiers` --- -ALTER TABLE `sh_modifiers` - ADD PRIMARY KEY (`id`), - ADD KEY `fk_mod_group` (`group_id`); - --- --- Indeksy dla tabeli `sh_modifier_groups` --- -ALTER TABLE `sh_modifier_groups` - ADD PRIMARY KEY (`id`); - --- --- Indeksy dla tabeli `sh_orders` --- -ALTER TABLE `sh_orders` - ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `uq_order_uuid` (`uuid`), - ADD KEY `tenant_id` (`tenant_id`), - ADD KEY `table_id` (`table_id`), - ADD KEY `created_by` (`created_by`); - --- --- Indeksy dla tabeli `sh_order_items` --- -ALTER TABLE `sh_order_items` - ADD PRIMARY KEY (`id`), - ADD KEY `order_id` (`order_id`), - ADD KEY `menu_item_sku` (`menu_item_sku`); - --- --- Indeksy dla tabeli `sh_order_item_modifiers` --- -ALTER TABLE `sh_order_item_modifiers` - ADD PRIMARY KEY (`id`), - ADD KEY `order_item_id` (`order_item_id`); - --- --- Indeksy dla tabeli `sh_price_tiers` --- -ALTER TABLE `sh_price_tiers` - ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `unique_tier_idx` (`target_type`,`target_sku`,`channel`); - --- --- Indeksy dla tabeli `sh_recipes` --- -ALTER TABLE `sh_recipes` - ADD PRIMARY KEY (`id`), - ADD KEY `fk_recipes_menu_v3` (`menu_item_sku`), - ADD KEY `fk_recipes_wh_v3` (`warehouse_sku`); - --- --- Indeksy dla tabeli `sh_recipe_steps` --- -ALTER TABLE `sh_recipe_steps` - ADD PRIMARY KEY (`id`), - ADD KEY `fk_steps_menu` (`menu_item_sku`); - --- --- Indeksy dla tabeli `sh_tables` --- -ALTER TABLE `sh_tables` - ADD PRIMARY KEY (`id`), - ADD KEY `tenant_id` (`tenant_id`); - --- --- Indeksy dla tabeli `sh_tenants` --- -ALTER TABLE `sh_tenants` - ADD PRIMARY KEY (`id`); - --- --- Indeksy dla tabeli `sh_users` --- -ALTER TABLE `sh_users` - ADD PRIMARY KEY (`id`), - ADD KEY `tenant_id` (`tenant_id`); - --- --- Indeksy dla tabeli `sh_work_sessions` --- -ALTER TABLE `sh_work_sessions` - ADD PRIMARY KEY (`id`), - ADD KEY `tenant_id` (`tenant_id`), - ADD KEY `user_id` (`user_id`); - --- --- Indeksy dla tabeli `sys_items` --- -ALTER TABLE `sys_items` - ADD PRIMARY KEY (`sku`), - ADD KEY `tenant_id` (`tenant_id`); - --- --- Indeksy dla tabeli `wh_inventory_docs` --- -ALTER TABLE `wh_inventory_docs` - ADD PRIMARY KEY (`id`), - ADD KEY `fk_whdocs_tenant` (`tenant_id`), - ADD KEY `fk_whdocs_user` (`created_by`); - --- --- Indeksy dla tabeli `wh_inventory_doc_items` --- -ALTER TABLE `wh_inventory_doc_items` - ADD PRIMARY KEY (`id`), - ADD KEY `fk_whdocitems_doc` (`doc_id`), - ADD KEY `fk_whdocitems_sku` (`sku`); - --- --- Indeksy dla tabeli `wh_stock` --- -ALTER TABLE `wh_stock` - ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `uq_stock_tenant_sku` (`tenant_id`,`sku`), - ADD KEY `fk_whstock_sku` (`sku`); - --- --- Indeksy dla tabeli `wh_stock_logs` --- -ALTER TABLE `wh_stock_logs` - ADD PRIMARY KEY (`id`), - ADD KEY `fk_logs_tenant` (`tenant_id`), - ADD KEY `fk_logs_sku` (`sku`), - ADD KEY `fk_logs_user` (`created_by`); - --- --- Indeksy dla tabeli `wh_uom_conversions` --- -ALTER TABLE `wh_uom_conversions` - ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `uq_uom_tenant_sku` (`tenant_id`,`sku`,`purchase_unit_name`), - ADD KEY `fk_uom_sku` (`sku`); - --- --- AUTO_INCREMENT for dumped tables --- - --- --- AUTO_INCREMENT for table `sh_categories` --- -ALTER TABLE `sh_categories` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=8; - --- --- AUTO_INCREMENT for table `sh_menu_items` --- -ALTER TABLE `sh_menu_items` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; - --- --- AUTO_INCREMENT for table `sh_modifiers` --- -ALTER TABLE `sh_modifiers` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=23; - --- --- AUTO_INCREMENT for table `sh_modifier_groups` --- -ALTER TABLE `sh_modifier_groups` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=13; - --- --- AUTO_INCREMENT for table `sh_orders` --- -ALTER TABLE `sh_orders` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- AUTO_INCREMENT for table `sh_order_items` --- -ALTER TABLE `sh_order_items` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- AUTO_INCREMENT for table `sh_order_item_modifiers` --- -ALTER TABLE `sh_order_item_modifiers` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- AUTO_INCREMENT for table `sh_price_tiers` --- -ALTER TABLE `sh_price_tiers` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=111; - --- --- AUTO_INCREMENT for table `sh_recipes` --- -ALTER TABLE `sh_recipes` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=16; - --- --- AUTO_INCREMENT for table `sh_recipe_steps` --- -ALTER TABLE `sh_recipe_steps` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- AUTO_INCREMENT for table `sh_tables` --- -ALTER TABLE `sh_tables` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- AUTO_INCREMENT for table `sh_tenants` --- -ALTER TABLE `sh_tenants` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2; - --- --- AUTO_INCREMENT for table `sh_users` --- -ALTER TABLE `sh_users` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- AUTO_INCREMENT for table `sh_work_sessions` --- -ALTER TABLE `sh_work_sessions` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- AUTO_INCREMENT for table `wh_inventory_docs` --- -ALTER TABLE `wh_inventory_docs` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=12; - --- --- AUTO_INCREMENT for table `wh_inventory_doc_items` --- -ALTER TABLE `wh_inventory_doc_items` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=10; - --- --- AUTO_INCREMENT for table `wh_stock` --- -ALTER TABLE `wh_stock` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=8; - --- --- AUTO_INCREMENT for table `wh_stock_logs` --- -ALTER TABLE `wh_stock_logs` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=10; - --- --- AUTO_INCREMENT for table `wh_uom_conversions` --- -ALTER TABLE `wh_uom_conversions` - MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; - --- --- Constraints for dumped tables --- - --- --- Constraints for table `sh_categories` --- -ALTER TABLE `sh_categories` - ADD CONSTRAINT `fk_categories_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sh_item_modifiers` --- -ALTER TABLE `sh_item_modifiers` - ADD CONSTRAINT `fk_item_mod_group` FOREIGN KEY (`group_id`) REFERENCES `sh_modifier_groups` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_item_mod_item` FOREIGN KEY (`item_id`) REFERENCES `sh_menu_items` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sh_menu_items` --- -ALTER TABLE `sh_menu_items` - ADD CONSTRAINT `fk_menu_category` FOREIGN KEY (`category_id`) REFERENCES `sh_categories` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_menu_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sh_modifiers` --- -ALTER TABLE `sh_modifiers` - ADD CONSTRAINT `fk_mod_group` FOREIGN KEY (`group_id`) REFERENCES `sh_modifier_groups` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sh_orders` --- -ALTER TABLE `sh_orders` - ADD CONSTRAINT `fk_orders_table` FOREIGN KEY (`table_id`) REFERENCES `sh_tables` (`id`) ON DELETE SET NULL, - ADD CONSTRAINT `fk_orders_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_orders_user` FOREIGN KEY (`created_by`) REFERENCES `sh_users` (`id`) ON DELETE SET NULL; - --- --- Constraints for table `sh_order_items` --- -ALTER TABLE `sh_order_items` - ADD CONSTRAINT `fk_orderitems_order` FOREIGN KEY (`order_id`) REFERENCES `sh_orders` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_orderitems_sku` FOREIGN KEY (`menu_item_sku`) REFERENCES `sh_menu_items` (`ascii_key`) ON DELETE CASCADE; - --- --- Constraints for table `sh_order_item_modifiers` --- -ALTER TABLE `sh_order_item_modifiers` - ADD CONSTRAINT `fk_modifiers_orderitem` FOREIGN KEY (`order_item_id`) REFERENCES `sh_order_items` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sh_recipes` --- -ALTER TABLE `sh_recipes` - ADD CONSTRAINT `fk_recipes_menu_v3` FOREIGN KEY (`menu_item_sku`) REFERENCES `sh_menu_items` (`ascii_key`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_recipes_wh_v3` FOREIGN KEY (`warehouse_sku`) REFERENCES `sys_items` (`sku`) ON DELETE CASCADE; - --- --- Constraints for table `sh_recipe_steps` --- -ALTER TABLE `sh_recipe_steps` - ADD CONSTRAINT `fk_steps_menu` FOREIGN KEY (`menu_item_sku`) REFERENCES `sh_menu_items` (`ascii_key`) ON DELETE CASCADE; - --- --- Constraints for table `sh_tables` --- -ALTER TABLE `sh_tables` - ADD CONSTRAINT `fk_tables_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sh_users` --- -ALTER TABLE `sh_users` - ADD CONSTRAINT `fk_users_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sh_work_sessions` --- -ALTER TABLE `sh_work_sessions` - ADD CONSTRAINT `fk_session_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `sh_users` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `sys_items` --- -ALTER TABLE `sys_items` - ADD CONSTRAINT `fk_sysitems_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `wh_inventory_docs` --- -ALTER TABLE `wh_inventory_docs` - ADD CONSTRAINT `fk_whdocs_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_whdocs_user` FOREIGN KEY (`created_by`) REFERENCES `sh_users` (`id`) ON DELETE SET NULL; - --- --- Constraints for table `wh_inventory_doc_items` --- -ALTER TABLE `wh_inventory_doc_items` - ADD CONSTRAINT `fk_whdocitems_doc` FOREIGN KEY (`doc_id`) REFERENCES `wh_inventory_docs` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_whdocitems_sku` FOREIGN KEY (`sku`) REFERENCES `sys_items` (`sku`) ON DELETE CASCADE; - --- --- Constraints for table `wh_stock` --- -ALTER TABLE `wh_stock` - ADD CONSTRAINT `fk_whstock_sku` FOREIGN KEY (`sku`) REFERENCES `sys_items` (`sku`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_whstock_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE; - --- --- Constraints for table `wh_stock_logs` --- -ALTER TABLE `wh_stock_logs` - ADD CONSTRAINT `fk_logs_sku` FOREIGN KEY (`sku`) REFERENCES `sys_items` (`sku`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_logs_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_logs_user` FOREIGN KEY (`created_by`) REFERENCES `sh_users` (`id`) ON DELETE SET NULL; - --- --- Constraints for table `wh_uom_conversions` --- -ALTER TABLE `wh_uom_conversions` - ADD CONSTRAINT `fk_uom_sku` FOREIGN KEY (`sku`) REFERENCES `sys_items` (`sku`) ON DELETE CASCADE, - ADD CONSTRAINT `fk_uom_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `sh_tenants` (`id`) ON DELETE CASCADE; -COMMIT; - -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +# BAZA DANYCH ÔÇö Schemat & Dokumentacja + +**Baza:** `slicehub_pro_v2` | **Silnik:** MariaDB 10.4+ / MySQL 8.0+ | **Kodowanie:** utf8mb4_unicode_ci + +--- + +## 1. KONWENCJE + +| Regu┼éa | Standard | +|--------|----------| +| Prefiks `sh_` | Tabele biznesowe SliceHub | +| Prefiks `sys_` | Tabele systemowe (surowce) | +| Prefiks `wh_` | Tabele magazynowe | +| `tenant_id` | Obowi─ůzkowy FK Ôćĺ `sh_tenant(id)` w ka┼╝dej tabeli danych | +| Kwoty pieni─Ö┼╝ne | `INT` w groszach (1 PLN = 100) ÔÇö zam├│wienia, rozliczenia | +| Ceny katalogowe | `DECIMAL(10,2)` w PLN ÔÇö sh_price_tiers, wh_stock | +| UUID | `CHAR(36)` ÔÇö zam├│wienia, linie, p┼éatno┼Ťci, audyt | +| Auto ID | `BIGINT UNSIGNED AUTO_INCREMENT` ÔÇö encje (users, items, categories) | +| Soft delete | `is_deleted TINYINT(1)` zamiast fizycznego DELETE | +| Statusy | `VARCHAR(32)` ÔÇö walidacja po stronie aplikacji, nie ENUM | + +--- + +## 2. TABELE + +### A. Core & Auth + +| Tabela | PK | Opis | +|--------|----|------| +| `sh_tenant` | `id` AI | Lokale / restauracje (multi-tenant) | +| `sh_tenant_settings` | `(tenant_id, setting_key)` | KV-store ustawie┼ä + pola SLA/prep | +| `sh_users` | `id` AI | Wszyscy u┼╝ytkownicy systemu | + +**sh_tenant_settings** ÔÇö podw├│jna rola: +- `setting_key = ''` Ôćĺ kolumny SLA (`min_prep_time_minutes`, `sla_green_min`...) +- `setting_key = 'half_half_surcharge'` Ôćĺ czyste KV (`setting_value`) + +**sh_users.role** Ôćĺ `owner` ┬Ě `manager` ┬Ě `waiter` ┬Ě `cook` ┬Ě `driver` ┬Ě `team` +**sh_users.pin_code** Ôćĺ logowanie kiosk-mode (POS / Driver App). Owner nie ma PINa. + +--- + +### B. Menu & Studio + +| Tabela | PK | Opis | +|--------|----|------| +| `sh_categories` | `id` AI | Kategorie menu | +| `sh_menu_items` | `id` AI | Pozycje menu; `ascii_key` = unikalny SKU | +| `sh_modifier_groups` | `id` AI | Grupy modyfikator├│w | +| `sh_modifiers` | `id` AI | Konkretne modyfikatory | +| `sh_item_modifiers` | `(item_id, group_id)` | M:N link pozycja Ôćö grupa modyfikator├│w | +| `sh_price_tiers` | `id` AI | Ceny omnichannel; UNIQUE `(target_type, target_sku, channel, tenant_id)` | +| `sh_recipes` | `id` AI | Receptury ÔÇö zu┼╝ycie surowca per pozycja menu | +| `sh_promo_codes` | `id` AI | Kody rabatowe | + +**sh_price_tiers:** +- `target_type` Ôćĺ `ITEM` / `MODIFIER` +- `channel` Ôćĺ `POS` / `Takeaway` / `Delivery` +- `target_sku` Ôćĺ `ascii_key` pozycji lub modyfikatora + +**Widok `sh_item_prices`** ÔÇö filtruje `sh_price_tiers` WHERE `target_type = 'ITEM'` + +**sh_modifiers.linked_warehouse_sku** ÔÇö opcjonalny link do surowca magazynowego (np. "Podw├│jny ser" Ôćĺ `SER_MOZZ` ├Ś 0.1 kg) + +--- + +### C. Orders & Fleet + +| Tabela | PK | Opis | +|--------|----|------| +| `sh_orders` | `id` CHAR(36) | Zam├│wienia ÔÇö kwoty w groszach | +| `sh_order_lines` | `id` CHAR(36) | Linie zam├│wienia ÔÇö modyfikatory w JSON | +| `sh_order_audit` | `id` AI | Historia zmian status├│w | +| `sh_order_payments` | `id` CHAR(36) | P┼éatno┼Ťci (split payment ready) | +| `sh_order_item_modifiers` | `id` AI | Modyfikatory per linia zam├│wienia | +| `sh_kds_tickets` | `id` CHAR(36) | Tickety KDS (Kitchen Display) | +| `sh_order_sequences` | `(tenant_id, date)` | Numerator zam├│wie┼ä dziennych | +| `sh_course_sequences` | `(tenant_id, date)` | Numerator kurs├│w dziennych | +| `sh_dispatch_log` | `id` CHAR(36) | Log kurs├│w dostawczych (K1, K2...) | +| `sh_delivery_zones` | `id` AI | Strefy dostawy (POLYGON) | +| `sh_sla_breaches` | `id` CHAR(36) | Naruszenia SLA | +| `sh_panic_log` | `id` CHAR(36) | Log Panic Button | + +**sh_orders ÔÇö statusy:** `new` Ôćĺ `pending` Ôćĺ `preparing` Ôćĺ `ready` Ôćĺ `in_delivery` Ôćĺ `completed` +**sh_orders.payment_status:** `unpaid` ┬Ě `paid` +**sh_orders.payment_method:** `cash` ┬Ě `card` ┬Ě `online` +**sh_orders.channel:** `pos` ┬Ě `online` +**sh_orders.order_type:** `dine_in` ┬Ě `takeaway` ┬Ě `delivery` +**sh_orders.course_id / stop_number:** wype┼éniane po dispatch (np. `K1`, `L1`) + +--- + +### D. Staff & HR + +| Tabela | PK | Opis | +|--------|----|------| +| `sh_drivers` | `(tenant_id, user_id)` | Rejestr kierowc├│w; FK Ôćĺ sh_users | +| `sh_driver_shifts` | `id` AI | Zmiany (kasa startowa, rozliczenie) | +| `sh_driver_locations` | `(tenant_id, driver_id)` | Real-time GPS ÔÇö UPSERT (migracja 008) | +| `sh_work_sessions` | `id` AI | Sesje pracy (start/end) | +| `sh_deductions` | `id` AI | Potr─ůcenia z wynagrodzenia | +| `sh_meals` | `id` AI | Posi┼éki pracownicze | + +**sh_drivers.status:** `offline` ┬Ě `available` ┬Ě `on_delivery` +**sh_driver_shifts:** `initial_cash` / `counted_cash` / `variance` ÔÇö w groszach + +--- + +### E. Warehouse + +| Tabela | PK | Opis | +|--------|----|------| +| `sys_items` | `id` AI | S┼éownik surowc├│w; `sku` = identyfikator; `base_unit` = kg/l/szt | +| `wh_stock` | `(tenant_id, warehouse_id, sku)` | Stany magazynowe + cena AVCO | +| `wh_documents` | `id` AI | Dokumenty (PZ/RW/MM/INW/WZ/KOR) | +| `wh_document_lines` | `id` AI | Linie dokument├│w (ilo┼Ť─ç, cena, VAT, AVCO) | +| `wh_stock_logs` | `id` AI | Audit log zmian stan├│w | +| `wh_inventory_docs` | `id` AI | Dokumenty inwentaryzacji | +| `wh_inventory_doc_items` | `id` AI | Linie inwentaryzacji | +| `sh_product_mapping` | `id` AI | Mapowanie faktura Ôćĺ SKU (AutoScan) | +| `sh_doc_sequences` | `(tenant_id, doc_type, doc_date)` | Numerator dokument├│w magazynowych | + +**wh_documents.type:** `PZ` (przyj─Öcie) ┬Ě `RW` (rozch├│d) ┬Ě `MM` (przesuni─Öcie) ┬Ě `INW` (inwentaryzacja) ┬Ě `WZ` (wydanie) ┬Ě `KOR` (korekta) +**wh_documents.status:** `pending_approval` ┬Ě `completed` +**wh_stock.current_avco_price:** ┼Ťrednia wa┼╝ona cena (AVCO) w PLN + +--- + +## 3. RELACJE + +``` +sh_tenant ÔöÇÔöÉ + ÔöťÔöÇÔöÇ sh_tenant_settings + ÔöťÔöÇÔöÇ sh_users + Ôöé ÔöťÔöÇÔöÇ sh_drivers ÔöÇÔöÇ sh_driver_locations + Ôöé ÔöťÔöÇÔöÇ sh_driver_shifts + Ôöé ÔöťÔöÇÔöÇ sh_work_sessions + Ôöé ÔöťÔöÇÔöÇ sh_deductions + Ôöé ÔööÔöÇÔöÇ sh_meals + ÔöťÔöÇÔöÇ sh_categories + Ôöé ÔööÔöÇÔöÇ sh_menu_items + Ôöé ÔöťÔöÇÔöÇ sh_item_modifiers Ôćĺ sh_modifier_groups Ôćĺ sh_modifiers + Ôöé ÔöťÔöÇÔöÇ sh_recipes Ôćĺ sys_items + Ôöé ÔööÔöÇÔöÇ sh_price_tiers + ÔöťÔöÇÔöÇ sh_orders + Ôöé ÔöťÔöÇÔöÇ sh_order_lines Ôćĺ sh_order_item_modifiers + Ôöé ÔöťÔöÇÔöÇ sh_order_audit + Ôöé ÔöťÔöÇÔöÇ sh_order_payments + Ôöé ÔööÔöÇÔöÇ sh_kds_tickets + ÔöťÔöÇÔöÇ wh_stock + ÔöťÔöÇÔöÇ wh_documents Ôćĺ wh_document_lines + ÔöťÔöÇÔöÇ wh_stock_logs + ÔööÔöÇÔöÇ sh_dispatch_log +``` + +--- + +## 4. MIGRACJE + +| Nr | Plik | Co dodaje | +|----|------|-----------| +| 001 | `001_init_slicehub_pro_v2.sql` | Grand schema ÔÇö wszystkie tabele, widoki, FK, indeksy | +| 004 | `004_expand_search_aliases.sql` | sys_items: `search_aliases`, `is_active`, `is_deleted` + polskie deklinacje | +| 006 | `006_studio_mission_control.sql` | sh_categories: VAT defaults / sh_menu_items: PLU, dost─Öpno┼Ť─ç | +| 007 | `007_pos_engine_columns.sql` | sh_orders: druk paragonu, cart_json, NIP | +| 008 | `008_delivery_ecosystem.sql` | Nowa tabela `sh_driver_locations` (GPS) | +| 009 | `009_delivery_state_machine.sql` | Delivery state machine | +| 010 | `010_driver_action_type.sql` | Driver action types | +| 011 | `011_integration_logs.sql` | Integration logs | +| 012 | `012_visual_layers.sql` | `sh_visual_layers` ÔÇö mapowanie modifier Ôćĺ warstwa wizualna | +| 013 | `013_board_companions.sql` | `sh_board_companions` ÔÇö cross-sell companions | +| 014 | `014_global_assets.sql` | `sh_global_assets` ÔÇö shared visual assets library | +| 015 | `015_normalize_three_drivers.sql` | Normalizacja kierowc├│w (3 konta testowe) | +| 016 | `016_visual_compositor_upgrade.sql` | `sh_visual_layers`: +`product_filename`, +`cal_scale`, +`cal_rotate`; `sh_board_companions`: +`product_filename`; `sh_tenant_settings`: +`storefront_surface_bg` | + +> Luki 002/003/005 ÔÇö dawne seedy przeniesione do `seed_demo_all.php`. +> Wszystkie migracje 004ÔÇô016 s─ů **idempotentne**. + +--- + +## 5. DANE TESTOWE ÔÇö `seed_demo_all.php` + +### Zakres + +| Obszar | Ilo┼Ť─ç | Szczeg├│┼éy | +|--------|-------|-----------| +| Tenant | 1 | "SliceHub Pizzeria Pozna┼ä" + 5 ustawie┼ä | +| U┼╝ytkownicy | 8 | Unikalne PINy 0000ÔÇô6666; has┼éo systemowe: `password` | +| Kategorie | 8 | Pizza, Burgery, Makarony, Sa┼éatki, Napoje, Dodatki, Desery, Zestawy | +| Pozycje menu | 33 | 10 pizz ┬Ě 5 burger├│w ┬Ě 3 makarony ┬Ě 2 sa┼éatki ┬Ě 5 napoj├│w ┬Ě 4 dodatki ┬Ě 2 desery ┬Ě 2 zestawy | +| Ceny | 112 | 99 ITEM + 13 MODIFIER ├Ś 3 kana┼éy (POS / Takeaway / Delivery +8%) | +| Modyfikatory | 4 grupy / 13 | Rozmiar pizzy ┬Ě Dodatki ┬Ě Sosy ┬Ě Rozmiar burgera | +| Surowce | 43 | M─ůka, sery, mi─Ösa, warzywa, napoje, opakowania | +| Stany magazynowe | 43 | Realistyczne ilo┼Ťci z cenami AVCO | +| Receptury | 44 linie | Margherita, Pepperoni, Capricciosa, Hawajska, Q.Formaggi, Burger Classic, Bolognese, Cezar, Frytki | +| Dokumenty WH | 4 | 3├Ś PZ (dostawy) + 1├Ś RW (strata) | +| Kierowcy | 2 | driver1 (PIN 4444) + driver2 (PIN 5555), zmiany + GPS Pozna┼ä | +| Zam├│wienia | 12 | 3 dine-in ┬Ě 2 takeaway ┬Ě 5 delivery ready ┬Ě 2 delivery completed | +| Sesje pracy | 6 | manager + waiter1/2 + cook1 + driver1/2 | + +### Konta testowe + +| Login | Rola | PIN | Modu┼é docelowy | +|-------|------|-----|----------------| +| `admin` | owner | ÔÇö | System login | +| `manager` | manager | 0000 | POS / Dispatch | +| `waiter1` | waiter | 1111 | POS | +| `waiter2` | waiter | 2222 | POS | +| `cook1` | cook | 3333 | KDS | +| `driver1` | driver | 4444 | Driver App | +| `driver2` | driver | 5555 | Driver App | +| `team1` | team | 6666 | Team App | diff --git a/api/backoffice/api_menu_studio.php b/api/backoffice/api_menu_studio.php index 48c023a..58c5cc2 100644 --- a/api/backoffice/api_menu_studio.php +++ b/api/backoffice/api_menu_studio.php @@ -4,15 +4,170 @@ header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + $response = ["success" => false, "data" => null, "message" => "Wyst─ůpi┼é b┼é─ůd serwera."]; try { require_once '../../core/db_config.php'; require_once '../../core/auth_guard.php'; + require_once '../../core/AssetResolver.php'; if (!isset($pdo)) { throw new Exception("Brak po┼é─ůczenia z baz─ů danych."); } + /** + * Kasuje poprzednie linki m021 dla dw├│ch r├│l i tworzy nowe (warstwa + hero dodatku). + */ + $syncModifierVisualAssetLinks = static function ( + PDO $pdo, + int $tenantId, + string $modifierSku, + ?int $layerAssetId, + ?int $heroAssetId + ): void { + if (!AssetResolver::isReady($pdo) || $modifierSku === '') { + return; + } + $del = $pdo->prepare( + "DELETE FROM sh_asset_links WHERE tenant_id = ? AND entity_type = 'modifier' + AND entity_ref = ? AND role IN ('layer_top_down','modifier_hero')" + ); + $del->execute([$tenantId, $modifierSku]); + + $slots = ['layer_top_down' => $layerAssetId, 'modifier_hero' => $heroAssetId]; + $ins = $pdo->prepare( + "INSERT INTO sh_asset_links (tenant_id, asset_id, entity_type, entity_ref, role, sort_order, is_active) + VALUES (?, ?, 'modifier', ?, ?, 0, 1)" + ); + foreach ($slots as $role => $aid) { + if ($aid === null || $aid <= 0) { + continue; + } + $chk = $pdo->prepare( + "SELECT id FROM sh_assets WHERE id = ? AND is_active = 1 AND deleted_at IS NULL + AND (tenant_id = 0 OR tenant_id = ?) LIMIT 1" + ); + $chk->execute([(int)$aid, $tenantId]); + if (!$chk->fetch(PDO::FETCH_ASSOC)) { + continue; + } + $ins->execute([$tenantId, (int)$aid, $modifierSku, $role]); + } + }; + + /** Czy sh_atelier_scenes ma kolumny M022 (scene_kind, parent_category_id). */ + $atelierHasCategoryCols = false; + try { + $pdo->query('SELECT scene_kind, parent_category_id FROM sh_atelier_scenes LIMIT 0'); + $atelierHasCategoryCols = true; + } catch (PDOException $e) { + } + + /** + * Tworzy lub podpina scen─Ö kategorii (scene_kind=category) i zwraca jej ID. + */ + $ensureCategoryAtelierScene = static function ( + PDO $pdo, + int $tenantId, + int $categoryId, + bool $hasCatCols + ): int { + if ($categoryId <= 0) { + throw new Exception('Nieprawid┼éowe ID kategorii.'); + } + $sentinel = '__CAT_SCENE_' . $categoryId; + if (strlen($sentinel) > 64) { + $sentinel = '__C_' . $categoryId; + } + + $defaultSpec = [ + 'version' => 1, + 'kind' => 'category_table', + 'template_key' => 'category_flat_table', + 'placements' => [], + ]; + + try { + $stmtCat = $pdo->prepare( + 'SELECT category_scene_id FROM sh_categories WHERE tenant_id = ? AND id = ? LIMIT 1' + ); + $stmtCat->execute([$tenantId, $categoryId]); + $catRow = $stmtCat->fetch(PDO::FETCH_ASSOC); + } catch (\PDOException $e) { + throw new Exception('Migracja M022 (kolumna category_scene_id) nie jest dost─Öpna.'); + } + if (!$catRow) { + throw new Exception('Kategoria nie istnieje.'); + } + $existingSid = isset($catRow['category_scene_id']) ? (int)$catRow['category_scene_id'] : 0; + + if ($existingSid > 0) { + $chk = $pdo->prepare('SELECT id FROM sh_atelier_scenes WHERE id = ? AND tenant_id = ? LIMIT 1'); + $chk->execute([$existingSid, $tenantId]); + if ($chk->fetch(PDO::FETCH_ASSOC)) { + if ($hasCatCols) { + $pdo->prepare( + "UPDATE sh_atelier_scenes SET scene_kind = 'category', parent_category_id = ? + WHERE id = ? AND tenant_id = ?" + )->execute([$categoryId, $existingSid, $tenantId]); + } + + return $existingSid; + } + } + + $stmtSent = $pdo->prepare( + 'SELECT id FROM sh_atelier_scenes WHERE tenant_id = ? AND item_sku = ? LIMIT 1' + ); + $stmtSent->execute([$tenantId, $sentinel]); + $sentRow = $stmtSent->fetch(PDO::FETCH_ASSOC); + if ($sentRow) { + $sid = (int)$sentRow['id']; + try { + $pdo->prepare('UPDATE sh_categories SET category_scene_id = ? WHERE tenant_id = ? AND id = ?') + ->execute([$sid, $tenantId, $categoryId]); + } catch (\PDOException $e) { + throw new Exception('Nie mo┼╝na powi─ůza─ç sceny z kategori─ů (category_scene_id).'); + } + if ($hasCatCols) { + $pdo->prepare( + "UPDATE sh_atelier_scenes SET scene_kind = 'category', parent_category_id = ? + WHERE id = ? AND tenant_id = ?" + )->execute([$categoryId, $sid, $tenantId]); + } + + return $sid; + } + + $specJson = json_encode($defaultSpec, JSON_UNESCAPED_UNICODE); + if ($hasCatCols) { + $ins = $pdo->prepare( + "INSERT INTO sh_atelier_scenes + (tenant_id, item_sku, spec_json, version, scene_kind, parent_category_id) + VALUES (?, ?, ?, 1, 'category', ?)" + ); + $ins->execute([$tenantId, $sentinel, $specJson, $categoryId]); + } else { + $ins = $pdo->prepare( + 'INSERT INTO sh_atelier_scenes (tenant_id, item_sku, spec_json, version) VALUES (?, ?, ?, 1)' + ); + $ins->execute([$tenantId, $sentinel, $specJson]); + } + $newId = (int)$pdo->lastInsertId(); + try { + $pdo->prepare('UPDATE sh_categories SET category_scene_id = ? WHERE tenant_id = ? AND id = ?') + ->execute([$newId, $tenantId, $categoryId]); + } catch (\PDOException $e) { + throw new Exception('Nie mo┼╝na powi─ůza─ç sceny z kategori─ů (category_scene_id).'); + } + + return $newId; + }; + // $tenant_id and $user_id are injected by auth_guard.php $inputJSON = file_get_contents('php://input'); @@ -26,6 +181,75 @@ try { // Helper: Puste warto┼Ťci na NULL dla bazy danych $toNull = function($val) { return ($val === '' || $val === null) ? null : $val; }; + // Schema detection: check once if we're on the new (v2) or legacy schema + $schemaV2 = false; + try { + $probe = $pdo->query("SELECT vat_rate_dine_in FROM sh_menu_items LIMIT 0"); + $schemaV2 = true; + } catch (PDOException $e) { + $schemaV2 = false; + } + + $catHasVat = false; + try { + $probe = $pdo->query("SELECT default_vat_dine_in FROM sh_categories LIMIT 0"); + $catHasVat = true; + } catch (PDOException $e) { + $catHasVat = false; + } + + $catHasIsDeleted = false; + try { + $probe = $pdo->query("SELECT is_deleted FROM sh_categories LIMIT 0"); + $catHasIsDeleted = true; + } catch (PDOException $e) { + $catHasIsDeleted = false; + } + + $hasPriceTiers = false; + try { + $probe = $pdo->query("SELECT 1 FROM sh_price_tiers LIMIT 0"); + $hasPriceTiers = true; + } catch (PDOException $e) { + $hasPriceTiers = false; + } + + $hasDriverActionType = false; + try { + $pdo->query("SELECT driver_action_type FROM sh_menu_items LIMIT 0"); + $hasDriverActionType = true; + } catch (PDOException $e) { + try { + $pdo->exec("ALTER TABLE sh_menu_items ADD COLUMN driver_action_type ENUM('none','pack_cold','pack_separate','check_id') NOT NULL DEFAULT 'none'"); + $hasDriverActionType = true; + } catch (PDOException $e2) {} + } + + // M022 feature detection ÔÇö Scene Kit & Composition Profile + $mi022HasCompositionProfile = false; + try { + $pdo->query("SELECT composition_profile FROM sh_menu_items LIMIT 0"); + $mi022HasCompositionProfile = true; + } catch (PDOException $e) {} + + $cat022HasLayout = false; + try { + $pdo->query("SELECT layout_mode, default_composition_profile FROM sh_categories LIMIT 0"); + $cat022HasLayout = true; + } catch (PDOException $e) {} + + $hasSceneTemplates = false; + try { + $pdo->query("SELECT 1 FROM sh_scene_templates LIMIT 0"); + $hasSceneTemplates = true; + } catch (PDOException $e) {} + + $hasModifierVisualImpact = false; + try { + $pdo->query("SELECT has_visual_impact FROM sh_modifiers LIMIT 0"); + $hasModifierVisualImpact = true; + } catch (PDOException $e) {} + switch ($action) { // ============================================================================== @@ -36,48 +260,955 @@ try { if (empty($catName)) { throw new Exception("Nazwa kategorii nie mo┼╝e by─ç pusta."); } - - // Wymuszamy domy┼Ťlne 0 dla display_order - $stmt = $pdo->prepare("INSERT INTO sh_categories (tenant_id, name, is_menu, display_order) VALUES (?, ?, 1, 0)"); - $stmt->execute([$tenant_id, $catName]); - + $catVatDineIn = floatval($input['defaultVatDineIn'] ?? 8); + $catVatTakeaway = floatval($input['defaultVatTakeaway'] ?? 5); + + // M022: layout mode + default composition profile (opcjonalne) + $catLayoutMode = in_array($input['layoutMode'] ?? '', ['grouped','individual','hybrid','legacy_list'], true) + ? $input['layoutMode'] : 'legacy_list'; + $catDefaultProfile = trim($input['defaultCompositionProfile'] ?? 'static_hero'); + if ($catDefaultProfile === '') $catDefaultProfile = 'static_hero'; + + if ($cat022HasLayout && $catHasVat) { + $stmt = $pdo->prepare("INSERT INTO sh_categories (tenant_id, name, is_menu, display_order, default_vat_dine_in, default_vat_takeaway, layout_mode, default_composition_profile) VALUES (?, ?, 1, 0, ?, ?, ?, ?)"); + $stmt->execute([$tenant_id, $catName, $catVatDineIn, $catVatTakeaway, $catLayoutMode, $catDefaultProfile]); + } elseif ($catHasVat) { + $stmt = $pdo->prepare("INSERT INTO sh_categories (tenant_id, name, is_menu, display_order, default_vat_dine_in, default_vat_takeaway) VALUES (?, ?, 1, 0, ?, ?)"); + $stmt->execute([$tenant_id, $catName, $catVatDineIn, $catVatTakeaway]); + } else { + $stmt = $pdo->prepare("INSERT INTO sh_categories (tenant_id, name, is_menu, display_order) VALUES (?, ?, 1, 0)"); + $stmt->execute([$tenant_id, $catName]); + } + $response['success'] = true; $response['data'] = ['categoryId' => $pdo->lastInsertId()]; $response['message'] = "Dodano now─ů kategori─Ö."; break; + case 'update_category': + $catId = intval($input['categoryId'] ?? 0); + $catName = trim($input['name'] ?? ''); + if ($catId <= 0 || empty($catName)) { + throw new Exception("ID kategorii i nazwa s─ů wymagane."); + } + $catVatDineIn = floatval($input['defaultVatDineIn'] ?? 8); + $catVatTakeaway = floatval($input['defaultVatTakeaway'] ?? 5); + + // M022: layout mode + default composition profile (opcjonalne) + $catLayoutMode = in_array($input['layoutMode'] ?? '', ['grouped','individual','hybrid','legacy_list'], true) + ? $input['layoutMode'] : 'legacy_list'; + $catDefaultProfile = trim($input['defaultCompositionProfile'] ?? 'static_hero'); + if ($catDefaultProfile === '') $catDefaultProfile = 'static_hero'; + + $catWhere = $catHasIsDeleted ? "AND is_deleted = 0" : ""; + if ($cat022HasLayout && $catHasVat) { + $stmt = $pdo->prepare("UPDATE sh_categories SET name = ?, default_vat_dine_in = ?, default_vat_takeaway = ?, layout_mode = ?, default_composition_profile = ? WHERE id = ? AND tenant_id = ? $catWhere"); + $stmt->execute([$catName, $catVatDineIn, $catVatTakeaway, $catLayoutMode, $catDefaultProfile, $catId, $tenant_id]); + } elseif ($catHasVat) { + $stmt = $pdo->prepare("UPDATE sh_categories SET name = ?, default_vat_dine_in = ?, default_vat_takeaway = ? WHERE id = ? AND tenant_id = ? $catWhere"); + $stmt->execute([$catName, $catVatDineIn, $catVatTakeaway, $catId, $tenant_id]); + } else { + $stmt = $pdo->prepare("UPDATE sh_categories SET name = ? WHERE id = ? AND tenant_id = ? $catWhere"); + $stmt->execute([$catName, $catId, $tenant_id]); + } + + $response['success'] = true; + $response['data'] = ['categoryId' => $catId]; + $response['message'] = "Zaktualizowano kategori─Ö."; + break; + + // ============================================================================== + // M023: Scene Kit ÔÇö backgrounds + props + lights + badges per template + // ============================================================================== + case 'get_scene_kit': + if (!$hasSceneTemplates) { + $response['success'] = true; + $response['data'] = [ + 'templateKey' => '', + 'template' => null, + 'kit' => ['backgrounds' => [], 'props' => [], 'lights' => [], 'badges' => []], + ]; + break; + } + $tplKey = trim($input['templateKey'] ?? ''); + if ($tplKey === '') { + throw new Exception("templateKey jest wymagany."); + } + + require_once __DIR__ . '/../../core/SceneResolver.php'; + $tpl = SceneResolver::getSceneTemplate($pdo, $tplKey); + $kit = SceneResolver::getSceneKitAssets($pdo, $tplKey); + + $response['success'] = true; + $response['data'] = [ + 'templateKey' => $tplKey, + 'template' => $tpl ? [ + 'asciiKey' => $tpl['ascii_key'], + 'name' => $tpl['name'], + 'kind' => $tpl['kind'], + 'stagePreset' => $tpl['stage_preset'], + 'compositionSchema' => $tpl['composition_schema'], + 'availableCameras' => $tpl['available_cameras'], + 'availableLuts' => $tpl['available_luts'], + 'atmosphericEffects' => $tpl['atmospheric_effects'], + 'photographerBrief' => $tpl['photographer_brief_md'], + 'pipelinePreset' => $tpl['pipeline_preset'], + ] : null, + 'kit' => $kit, + 'counts' => [ + 'backgrounds' => count($kit['backgrounds']), + 'props' => count($kit['props']), + 'lights' => count($kit['lights']), + 'badges' => count($kit['badges']), + ], + ]; + break; + + // ============================================================================== + // M023.7 ┬Ě Scene Kit Editor ÔÇö zapis scene_kit_assets_json per template. + // + // Wej┼Ťcie: { templateKey, kit: { backgrounds:int[], props:int[], lights:int[], badges:int[] } } + // Semantyka: + // - Pr├│buje znale┼║─ç template tenanta (tenant_id = $tenant_id) o tym asciiKey. + // - Je┼Ťli nie istnieje, a istnieje system template (tenant_id = 0) ÔÇö klonuje + // go do tenant-specific (copy metadanych + nowy scene_kit_assets_json). + // - Odpowied┼║: { templateId, cloned }. System template pozostaje nietkni─Öty. + // ============================================================================== + case 'save_scene_kit': + if (!$hasSceneTemplates) { + throw new Exception('Migracja M022 (sh_scene_templates) nie jest dost─Öpna.'); + } + $tplKey = trim($input['templateKey'] ?? ''); + if ($tplKey === '') { + throw new Exception('templateKey jest wymagany.'); + } + $kitIn = is_array($input['kit'] ?? null) ? $input['kit'] : []; + $kit = []; + foreach (['backgrounds', 'props', 'lights', 'badges'] as $k) { + $raw = isset($kitIn[$k]) && is_array($kitIn[$k]) ? $kitIn[$k] : []; + $ids = []; + foreach ($raw as $v) { + $id = (int)$v; + if ($id > 0 && !in_array($id, $ids, true)) { + $ids[] = $id; + } + } + $kit[$k] = $ids; + } + + $allIds = array_merge($kit['backgrounds'], $kit['props'], $kit['lights'], $kit['badges']); + if (!empty($allIds)) { + $ph = implode(',', array_fill(0, count($allIds), '?')); + $chk = $pdo->prepare( + "SELECT id FROM sh_assets + WHERE id IN ($ph) AND (tenant_id = 0 OR tenant_id = ?) AND deleted_at IS NULL AND is_active = 1" + ); + $chk->execute(array_merge($allIds, [$tenant_id])); + $validIds = array_map('intval', $chk->fetchAll(PDO::FETCH_COLUMN)); + foreach ($kit as $k => $ids) { + $kit[$k] = array_values(array_filter($ids, fn($id) => in_array($id, $validIds, true))); + } + } + + $tenantTpl = $pdo->prepare( + "SELECT id FROM sh_scene_templates + WHERE tenant_id = ? AND ascii_key = ? LIMIT 1" + ); + $tenantTpl->execute([$tenant_id, $tplKey]); + $tenantTplId = (int)($tenantTpl->fetchColumn() ?: 0); + + $cloned = false; + if ($tenantTplId === 0) { + $sysTpl = $pdo->prepare( + "SELECT ascii_key, name, kind, stage_preset_json, composition_schema_json, + available_cameras_json, available_luts_json, atmospheric_effects_json, + photographer_brief_md, pipeline_preset_json, + default_style_id, placeholder_asset_id + FROM sh_scene_templates + WHERE tenant_id = 0 AND ascii_key = ? LIMIT 1" + ); + $sysTpl->execute([$tplKey]); + $sys = $sysTpl->fetch(PDO::FETCH_ASSOC); + if (!$sys) { + throw new Exception('Nie znaleziono template o tym asciiKey (ani tenant, ani system).'); + } + $ins = $pdo->prepare( + "INSERT INTO sh_scene_templates + (tenant_id, ascii_key, name, kind, stage_preset_json, composition_schema_json, + available_cameras_json, available_luts_json, atmospheric_effects_json, + scene_kit_assets_json, + photographer_brief_md, pipeline_preset_json, + default_style_id, placeholder_asset_id, + is_system, is_active) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,1)" + ); + $ins->execute([ + $tenant_id, + $sys['ascii_key'], + $sys['name'], + $sys['kind'], + $sys['stage_preset_json'], + $sys['composition_schema_json'], + $sys['available_cameras_json'], + $sys['available_luts_json'], + $sys['atmospheric_effects_json'], + json_encode($kit, JSON_UNESCAPED_UNICODE), + $sys['photographer_brief_md'], + $sys['pipeline_preset_json'], + $sys['default_style_id'], + $sys['placeholder_asset_id'], + ]); + $tenantTplId = (int)$pdo->lastInsertId(); + $cloned = true; + } else { + $upd = $pdo->prepare( + "UPDATE sh_scene_templates SET scene_kit_assets_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND tenant_id = ? LIMIT 1" + ); + $upd->execute([ + json_encode($kit, JSON_UNESCAPED_UNICODE), + $tenantTplId, + $tenant_id, + ]); + } + + $response['success'] = true; + $response['data'] = [ + 'templateId' => $tenantTplId, + 'cloned' => $cloned, + 'kitCounts' => [ + 'backgrounds' => count($kit['backgrounds']), + 'props' => count($kit['props']), + 'lights' => count($kit['lights']), + 'badges' => count($kit['badges']), + ], + ]; + $response['message'] = $cloned + ? 'Utworzono tenant-specific wersj─Ö szablonu i zapisano kit.' + : 'Zapisano scene kit.'; + break; + + // ============================================================================== + // M022: Lista scene templates ÔÇö dla select├│w w Menu Studio UI + // ============================================================================== + case 'list_scene_templates': + if (!$hasSceneTemplates) { + $response['success'] = true; + $response['data'] = ['templates' => []]; + break; + } + $kindFilter = in_array($input['kind'] ?? '', ['item','category'], true) ? $input['kind'] : null; + if ($kindFilter) { + $stmt = $pdo->prepare( + "SELECT ascii_key, name, kind, photographer_brief_md, is_system + FROM sh_scene_templates + WHERE (tenant_id = 0 OR tenant_id = ?) AND is_active = 1 AND kind = ? + ORDER BY (tenant_id = 0) DESC, name ASC" + ); + $stmt->execute([$tenant_id, $kindFilter]); + } else { + $stmt = $pdo->prepare( + "SELECT ascii_key, name, kind, photographer_brief_md, is_system + FROM sh_scene_templates + WHERE (tenant_id = 0 OR tenant_id = ?) AND is_active = 1 + ORDER BY kind ASC, (tenant_id = 0) DESC, name ASC" + ); + $stmt->execute([$tenant_id]); + } + $tpls = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $t) { + $tpls[] = [ + 'asciiKey' => (string)$t['ascii_key'], + 'name' => (string)$t['name'], + 'kind' => (string)$t['kind'], + 'photographerBrief' => $t['photographer_brief_md'] ?? null, + 'isSystem' => (bool)$t['is_system'], + ]; + } + $response['success'] = true; + $response['data'] = ['templates' => $tpls]; + break; + + // ============================================================================== + // M024: Biblioteka asset├│w ÔÇö picker (Menu Studio ┬Ě modyfikatory) + // ============================================================================== + case 'list_assets_compact': + if (!AssetResolver::isReady($pdo)) { + $response['success'] = true; + $response['data'] = ['assets' => []]; + break; + } + $lim = (int)($input['limit'] ?? 500); + $lim = max(50, min(800, $lim)); + $stmt = $pdo->prepare( + "SELECT id, ascii_key, storage_url, category, role_hint, sub_type + FROM sh_assets + WHERE (tenant_id = 0 OR tenant_id = ?) AND is_active = 1 AND deleted_at IS NULL + ORDER BY tenant_id DESC, category ASC, ascii_key ASC + LIMIT {$lim}" + ); + $stmt->execute([$tenant_id]); + $assets = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { + $assets[] = [ + 'id' => (int)$r['id'], + 'asciiKey' => (string)$r['ascii_key'], + 'previewUrl'=> AssetResolver::publicUrl((string)$r['storage_url']), + 'category' => $r['category'], + 'roleHint' => $r['role_hint'], + 'subType' => $r['sub_type'], + ]; + } + $response['success'] = true; + $response['data'] = ['assets' => $assets]; + break; + + // ============================================================================== + // M024: Wizualne sloty modyfikatora (layer_top_down + modifier_hero) + // ============================================================================== + case 'get_modifier_visual': + $modSku = preg_replace('/[^a-zA-Z0-9_-]/', '', $input['modifierSku'] ?? ''); + if ($modSku === '') { + throw new Exception('modifierSku jest wymagany.'); + } + $modCols = 'm.id, m.name, m.ascii_key, m.group_id'; + if ($hasModifierVisualImpact) { + $modCols .= ', m.has_visual_impact'; + } + $stmt = $pdo->prepare( + "SELECT {$modCols} + FROM sh_modifiers m + INNER JOIN sh_modifier_groups mg ON mg.id = m.group_id AND mg.tenant_id = ? + WHERE m.ascii_key = ? AND m.is_deleted = 0 + LIMIT 1" + ); + $stmt->execute([$tenant_id, $modSku]); + $mod = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$mod) { + throw new Exception('Nie znaleziono modyfikatora.'); + } + $hasVi = true; + if ($hasModifierVisualImpact) { + $hasVi = (bool)((int)($mod['has_visual_impact'] ?? 1)); + } + $slots = [ + 'layer_top_down' => null, + 'modifier_hero' => null, + ]; + if (AssetResolver::isReady($pdo)) { + $st2 = $pdo->prepare( + "SELECT al.role, al.asset_id, a.ascii_key, a.storage_url + FROM sh_asset_links al + INNER JOIN sh_assets a ON a.id = al.asset_id AND a.is_active = 1 AND a.deleted_at IS NULL + WHERE al.tenant_id = ? AND al.entity_type = 'modifier' AND al.entity_ref = ? + AND al.role IN ('layer_top_down','modifier_hero') AND al.is_active = 1 AND al.deleted_at IS NULL + ORDER BY al.sort_order ASC, al.id DESC" + ); + $st2->execute([$tenant_id, $modSku]); + foreach ($st2->fetchAll(PDO::FETCH_ASSOC) as $row) { + $rk = (string)$row['role']; + if (!isset($slots[$rk]) || $slots[$rk] !== null) { + continue; + } + $slots[$rk] = [ + 'assetId' => (int)$row['asset_id'], + 'asciiKey' => (string)$row['ascii_key'], + 'previewUrl' => AssetResolver::publicUrl((string)$row['storage_url']), + ]; + } + } + $response['success'] = true; + $response['data'] = [ + 'modifierId' => (int)$mod['id'], + 'modifierSku' => (string)$mod['ascii_key'], + 'name' => (string)$mod['name'], + 'hasVisualImpact' => $hasVi, + 'slots' => $slots, + 'libraryReady' => AssetResolver::isReady($pdo), + ]; + break; + + case 'save_modifier_visual': + $modSku = preg_replace('/[^a-zA-Z0-9_-]/', '', $input['modifierSku'] ?? ''); + if ($modSku === '') { + throw new Exception('modifierSku jest wymagany.'); + } + $stmt = $pdo->prepare( + "SELECT m.id FROM sh_modifiers m + INNER JOIN sh_modifier_groups mg ON mg.id = m.group_id AND mg.tenant_id = ? + WHERE m.ascii_key = ? AND m.is_deleted = 0 LIMIT 1" + ); + $stmt->execute([$tenant_id, $modSku]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + throw new Exception('Nie znaleziono modyfikatora.'); + } + $modId = (int)$row['id']; + $hasVi = array_key_exists('hasVisualImpact', $input) + ? (!empty($input['hasVisualImpact']) ? 1 : 0) + : 1; + $layerId = isset($input['layerTopDownAssetId']) ? (int)$input['layerTopDownAssetId'] : 0; + $heroId = isset($input['modifierHeroAssetId']) ? (int)$input['modifierHeroAssetId'] : 0; + $layerId = $layerId > 0 ? $layerId : null; + $heroId = $heroId > 0 ? $heroId : null; + + if ($hasModifierVisualImpact) { + $pdo->prepare('UPDATE sh_modifiers SET has_visual_impact = ? WHERE id = ?') + ->execute([$hasVi, $modId]); + } + $syncModifierVisualAssetLinks($pdo, $tenant_id, $modSku, $layerId, $heroId); + + $response['success'] = true; + $response['message'] = 'Zapisano ustawienia wizualne modyfikatora.'; + $response['data'] = ['modifierId' => $modId]; + break; + + // ============================================================================== + // M1 ┬Ě Menu Studio Polish ÔÇö Przypisanie hero z biblioteki do dania + // Jedno danie = JEDEN hero (role='hero', entity_type='menu_item'). Akcja + // najpierw "soft-deletuje" istniej─ůcy link hero, potem upsertuje nowy. + // Akceptuje assetId (globalny sh_assets.tenant_id=0 lub nasz tenant). + // ============================================================================== + case 'set_item_hero': + if (!AssetResolver::isReady($pdo)) { + throw new Exception('Biblioteka asset├│w (m021) nie jest jeszcze zainicjalizowana.'); + } + $itemSku = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($input['itemSku'] ?? '')); + $assetId = (int)($input['assetId'] ?? 0); + if ($itemSku === '') { + throw new Exception('itemSku jest wymagany.'); + } + if ($assetId <= 0) { + throw new Exception('assetId jest wymagany.'); + } + + $chkItem = $pdo->prepare( + "SELECT id FROM sh_menu_items + WHERE tenant_id = ? AND ascii_key = ? AND is_deleted = 0 LIMIT 1" + ); + $chkItem->execute([$tenant_id, $itemSku]); + if (!$chkItem->fetch(PDO::FETCH_ASSOC)) { + throw new Exception('Nie znaleziono dania o SKU: ' . $itemSku); + } + + $chkAsset = $pdo->prepare( + "SELECT id, storage_url FROM sh_assets + WHERE id = ? AND is_active = 1 AND deleted_at IS NULL + AND (tenant_id = 0 OR tenant_id = ?) LIMIT 1" + ); + $chkAsset->execute([$assetId, $tenant_id]); + $assetRow = $chkAsset->fetch(PDO::FETCH_ASSOC); + if (!$assetRow) { + throw new Exception('Asset nie istnieje lub jest niedost─Öpny.'); + } + + $pdo->beginTransaction(); + try { + $pdo->prepare( + "UPDATE sh_asset_links SET deleted_at = CURRENT_TIMESTAMP, is_active = 0 + WHERE tenant_id = ? AND entity_type = 'menu_item' + AND entity_ref = ? AND role = 'hero' + AND is_active = 1 AND deleted_at IS NULL" + )->execute([$tenant_id, $itemSku]); + + $pdo->prepare( + "INSERT INTO sh_asset_links + (tenant_id, asset_id, entity_type, entity_ref, role, sort_order, is_active, created_at) + VALUES (?, ?, 'menu_item', ?, 'hero', 0, 1, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + sort_order = 0, is_active = 1, deleted_at = NULL, + updated_at = CURRENT_TIMESTAMP" + )->execute([$tenant_id, $assetId, $itemSku]); + + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw new Exception('Nie uda┼éo si─Ö zapisa─ç linku hero: ' . $e->getMessage()); + } + + $newUrl = AssetResolver::publicUrl((string)$assetRow['storage_url']); + $response['success'] = true; + $response['message'] = 'Hero przypisany do dania.'; + $response['data'] = [ + 'itemSku' => $itemSku, + 'assetId' => $assetId, + 'imageUrl' => $newUrl, + ]; + break; + + // ============================================================================== + // M1 ┬Ě Menu Studio Polish ÔÇö Od┼é─ůczenie hero od dania (soft-delete linku) + // ============================================================================== + case 'unlink_item_hero': + if (!AssetResolver::isReady($pdo)) { + throw new Exception('Biblioteka asset├│w (m021) nie jest jeszcze zainicjalizowana.'); + } + $itemSku = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($input['itemSku'] ?? '')); + if ($itemSku === '') { + throw new Exception('itemSku jest wymagany.'); + } + $upd = $pdo->prepare( + "UPDATE sh_asset_links SET deleted_at = CURRENT_TIMESTAMP, is_active = 0 + WHERE tenant_id = ? AND entity_type = 'menu_item' + AND entity_ref = ? AND role = 'hero' + AND is_active = 1 AND deleted_at IS NULL" + ); + $upd->execute([$tenant_id, $itemSku]); + $response['success'] = true; + $response['message'] = $upd->rowCount() > 0 + ? 'Hero od┼é─ůczony.' + : 'Nie by┼éo aktywnego linku hero.'; + $response['data'] = ['itemSku' => $itemSku, 'removed' => $upd->rowCount()]; + break; + + // ============================================================================== + // M1 ┬Ě Menu Studio Polish ÔÇö Auto-generator default composition dania + // Sk┼éada scen─Ö z: (1) hero dania jako base layer, (2) modyfikator├│w z + // action_type='NONE' + is_default=1 + has_visual_impact=1 i przypisanymi + // assetami w roli layer_top_down. Zapis do sh_atelier_scenes.spec_json. + // Respektuje istniej─ůce sceny (force=true ┼╝eby nadpisa─ç). + // ============================================================================== + case 'autogenerate_scene': + require_once __DIR__ . '/../../core/SceneResolver.php'; + require_once __DIR__ . '/../../core/AssetResolver.php'; + + $itemSku = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($input['itemSku'] ?? '')); + $force = !empty($input['force']); + if ($itemSku === '') { + throw new Exception('itemSku jest wymagany.'); + } + if (!SceneResolver::isReady($pdo)) { + throw new Exception('Auto-generator wymaga migracji M022 (sh_atelier_scenes / sh_scene_templates).'); + } + + $stmt = $pdo->prepare( + "SELECT id, name, category_id, ascii_key + FROM sh_menu_items + WHERE tenant_id = ? AND ascii_key = ? AND is_deleted = 0 + LIMIT 1" + ); + $stmt->execute([$tenant_id, $itemSku]); + $item = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$item) { + throw new Exception('Nie znaleziono dania o SKU: ' . $itemSku); + } + + $stmt = $pdo->prepare( + "SELECT id, spec_json, version FROM sh_atelier_scenes + WHERE tenant_id = ? AND item_sku = ? LIMIT 1" + ); + $stmt->execute([$tenant_id, $itemSku]); + $existingScene = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($existingScene && !$force) { + $existingSpec = json_decode((string)$existingScene['spec_json'], true); + $existingLayers = $existingSpec['pizza']['layers'] ?? []; + if (is_array($existingLayers) && count($existingLayers) > 0) { + $response['success'] = false; + $response['data'] = [ + 'reason' => 'scene_exists', + 'sceneId' => (int)$existingScene['id'], + 'layerCount' => count($existingLayers), + 'version' => (int)$existingScene['version'], + ]; + $response['message'] = 'Scena ju┼╝ istnieje (' . count($existingLayers) . ' warstw). Wy┼Ťlij force=true, aby nadpisa─ç.'; + break; + } + } + + $layers = []; + $nextZ = 0; + + if (AssetResolver::isReady($pdo)) { + $stmtH = $pdo->prepare( + "SELECT a.ascii_key, a.storage_url + FROM sh_asset_links al + INNER JOIN sh_assets a ON a.id = al.asset_id AND a.is_active = 1 AND a.deleted_at IS NULL + WHERE al.tenant_id = ? AND al.entity_type = 'menu_item' + AND al.entity_ref = ? AND al.role = 'hero' + AND al.is_active = 1 AND al.deleted_at IS NULL + ORDER BY al.sort_order ASC, al.id DESC + LIMIT 1" + ); + $stmtH->execute([$tenant_id, $itemSku]); + $hero = $stmtH->fetch(PDO::FETCH_ASSOC); + if ($hero) { + $heroUrl = AssetResolver::publicUrl((string)$hero['storage_url']); + if ($heroUrl) { + $layers[] = [ + 'layerSku' => 'BASE_' . (string)$hero['ascii_key'], + 'assetUrl' => $heroUrl, + 'zIndex' => 0, + 'isBase' => true, + 'calScale' => 1.0, + 'calRotate' => 0, + 'offsetX' => 0.0, + 'offsetY' => 0.0, + 'visible' => true, + 'source' => 'auto_hero', + ]; + $nextZ = 10; + } + } + } + + $modVisualFilter = $hasModifierVisualImpact ? 'AND m.has_visual_impact = 1' : ''; + $sqlMods = " + SELECT DISTINCT + m.id, m.ascii_key, m.name, m.is_default, + mg.id AS group_id, mg.name AS group_name, + a.ascii_key AS asset_key, a.storage_url AS asset_url + FROM sh_item_modifiers im + INNER JOIN sh_menu_items mi ON mi.id = im.item_id AND mi.tenant_id = :tid + INNER JOIN sh_modifier_groups mg ON mg.id = im.group_id AND mg.tenant_id = :tid + INNER JOIN sh_modifiers m ON m.group_id = mg.id AND m.is_deleted = 0 + INNER JOIN sh_asset_links al ON al.tenant_id = :tid + AND al.entity_type = 'modifier' + AND al.entity_ref = m.ascii_key + AND al.role = 'layer_top_down' + AND al.is_active = 1 AND al.deleted_at IS NULL + INNER JOIN sh_assets a ON a.id = al.asset_id + AND a.is_active = 1 AND a.deleted_at IS NULL + WHERE mi.id = :item_id + AND m.action_type = 'NONE' + AND m.is_default = 1 + {$modVisualFilter} + ORDER BY mg.id ASC, m.id ASC + "; + try { + $stmtM = $pdo->prepare($sqlMods); + $stmtM->execute([':tid' => $tenant_id, ':item_id' => (int)$item['id']]); + $modRows = $stmtM->fetchAll(PDO::FETCH_ASSOC); + } catch (\PDOException $e) { + $modRows = []; + } + + foreach ($modRows as $row) { + $url = AssetResolver::publicUrl((string)$row['asset_url']); + if (!$url) continue; + $layers[] = [ + 'layerSku' => (string)$row['ascii_key'], + 'assetUrl' => $url, + 'zIndex' => $nextZ, + 'isBase' => false, + 'calScale' => 1.0, + 'calRotate' => 0, + 'offsetX' => 0.0, + 'offsetY' => 0.0, + 'visible' => true, + 'source' => 'auto_modifier', + 'fromModifier' => (string)$row['ascii_key'], + 'fromGroup' => (string)$row['group_name'], + ]; + $nextZ += 10; + } + + if (count($layers) === 0) { + $hasHero = isset($hero) && $hero ? true : false; + + $diagStmt = $pdo->prepare(" + SELECT COUNT(*) AS cnt + FROM sh_item_modifiers im + INNER JOIN sh_modifiers m ON m.group_id = im.group_id + AND m.is_deleted = 0 + AND m.action_type = 'NONE' + AND m.is_default = 1 + WHERE im.item_id = :item_id + "); + $diagStmt->execute([':item_id' => (int)$item['id']]); + $defaultModsCount = (int)($diagStmt->fetchColumn() ?: 0); + + $steps = []; + if (!$hasHero) { + $steps[] = 'Przypisz zdj─Öcie do tego dania ÔÇö kliknij ÔÇ×Przypisz Hero" pod miniatur─ů po lewej (biblioteka asset├│w otworzy si─Ö w pickerze).'; + } + if ($defaultModsCount === 0) { + $steps[] = 'Dodaj do tego dania przynajmniej jeden modyfikator domy┼Ťlny (np. grupa ÔÇ×Sos podstawowy" Ôćĺ opcja ÔÇ×Pomidorowy" z is_default = 1).'; + } else { + $steps[] = 'Masz ' . $defaultModsCount . ' modyfikator(y/├│w) domy┼Ťlnych, ale ┼╝aden nie ma przypisanej warstwy wizualnej (layer_top_down). Otw├│rz ÔÇ×Dodatki i Modyfikatory" i w sekcji ÔÇ×Surface ÔÇö wizualne sloty" wybierz warstw─Ö.'; + } + + $response['success'] = false; + $response['data'] = [ + 'reason' => 'no_source_data', + 'hasHero' => $hasHero, + 'defaultModsCount' => $defaultModsCount, + 'modsWithLayerCount'=> 0, + 'steps' => $steps, + ]; + $response['message'] = 'Brak materia┼éu do auto-generacji ÔÇö uzupe┼énij hero i/lub modyfikatory domy┼Ťlne z warstw─ů wizualn─ů.'; + break; + } + + $spec = [ + 'pizza' => ['layers' => $layers], + 'meta' => [ + 'generatedAt' => gmdate('c'), + 'generatedBy' => 'menu_studio_autogen', + 'sourceLayerCount' => count($layers), + 'hasHero' => !empty($layers) && !empty($layers[0]['isBase']), + 'modifierCount' => count($modRows), + ], + ]; + $specJson = json_encode($spec, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($specJson === false) { + throw new Exception('Nie uda┼éo si─Ö serializowa─ç spec_json.'); + } + + $pdo->beginTransaction(); + try { + if ($existingScene) { + $pdo->prepare( + "UPDATE sh_atelier_scenes + SET spec_json = ?, version = version + 1 + WHERE id = ? AND tenant_id = ?" + )->execute([$specJson, (int)$existingScene['id'], $tenant_id]); + $sceneId = (int)$existingScene['id']; + } else { + $pdo->prepare( + "INSERT INTO sh_atelier_scenes (tenant_id, item_sku, spec_json, version) + VALUES (?, ?, ?, 1)" + )->execute([$tenant_id, $itemSku, $specJson]); + $sceneId = (int)$pdo->lastInsertId(); + } + + try { + $pdo->prepare( + "INSERT INTO sh_atelier_scene_history (scene_id, spec_json, snapshot_label) + VALUES (?, ?, ?)" + )->execute([$sceneId, $specJson, 'autogen_' . gmdate('Ymd_His')]); + } catch (\PDOException $e) { + // history table mo┼╝e nie istnie─ç w starych instancjach ÔÇö ignore + } + + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + + $response['success'] = true; + $response['message'] = sprintf( + 'Wygenerowano scen─Ö z %d warstw (%s). %s', + count($layers), + count($layers) > 0 && !empty($layers[0]['isBase']) ? 'hero + ' . (count($layers) - 1) . ' modyfikator├│w' : (count($layers) . ' modyfikator├│w'), + $existingScene ? 'Nadpisano istniej─ůc─ů scen─Ö.' : 'Utworzono now─ů scen─Ö.' + ); + $response['data'] = [ + 'sceneId' => $sceneId, + 'itemSku' => $itemSku, + 'layerCount' => count($layers), + 'modifierCount' => count($modRows), + 'overwritten' => (bool)$existingScene, + 'spec' => $spec, + ]; + break; + + // ============================================================================== + // M025 ┬Ě Category Table ÔÇö edytor uk┼éadu da┼ä na wsp├│lnej scenie kategorii + // ============================================================================== + case 'get_category_scene_editor': + if (!$cat022HasLayout) { + throw new Exception('Uk┼éad sto┼éu kategorii wymaga migracji M022 (layout_mode / category_scene_id).'); + } + require_once __DIR__ . '/../../core/SceneResolver.php'; + $cid = (int)($input['categoryId'] ?? 0); + if ($cid <= 0) { + throw new Exception('categoryId jest wymagane.'); + } + $resolved = SceneResolver::resolveCategoryScene($pdo, (int)$tenant_id, $cid); + if (!$resolved) { + throw new Exception('Nie znaleziono kategorii.'); + } + $spec = is_array($resolved['scene_spec'] ?? null) ? $resolved['scene_spec'] : []; + $templateKey = isset($spec['template_key']) ? (string)$spec['template_key'] : 'category_flat_table'; + $placementsBySku = []; + foreach (($spec['placements'] ?? []) as $row) { + if (!empty($row['sku'])) { + $placementsBySku[(string)$row['sku']] = [ + 'sku' => (string)$row['sku'], + 'x' => isset($row['x']) ? max(0.0, min(1.0, (float)$row['x'])) : 0.5, + 'y' => isset($row['y']) ? max(0.0, min(1.0, (float)$row['y'])) : 0.5, + 'scale' => isset($row['scale']) ? max(0.3, min(3.0, (float)$row['scale'])) : 1.0, + 'z_index' => isset($row['z_index']) ? max(0, min(500, (int)$row['z_index'])) : 40, + ]; + } + } + $editorItems = []; + foreach (($resolved['items'] ?? []) as $it) { + $sku = (string)($it['sku'] ?? ''); + if ($sku === '') { + continue; + } + $editorItems[] = [ + 'sku' => $sku, + 'name' => (string)($it['name'] ?? ''), + 'placement' => $placementsBySku[$sku] ?? null, + 'compositionProfile' => $it['composition_profile'] ?? 'static_hero', + ]; + } + $sceneId = isset($resolved['scene_meta']['scene_id']) ? (int)$resolved['scene_meta']['scene_id'] : null; + + $response['success'] = true; + $response['data'] = [ + 'categoryId' => (int)$resolved['category_id'], + 'categoryName' => (string)$resolved['category_name'], + 'layoutMode' => (string)$resolved['layout_mode'], + 'sceneId' => $sceneId > 0 ? $sceneId : null, + 'templateKey' => $templateKey, + 'specVersion' => isset($spec['version']) ? (int)$spec['version'] : 1, + 'items' => $editorItems, + 'hints' => [ + 'coordinates' => 'x,y Ôłł [0..1] ÔÇö pozycja ┼Ťrodka ÔÇ×talerzaÔÇŁ na stole (wzgl─Ödem szer./wys. sceny).', + ], + ]; + break; + + case 'save_category_scene_layout': + if (!$cat022HasLayout) { + throw new Exception('Zapis uk┼éadu wymaga migracji M022.'); + } + $cid = (int)($input['categoryId'] ?? 0); + if ($cid <= 0) { + throw new Exception('categoryId jest wymagane.'); + } + $tplKey = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($input['templateKey'] ?? '')); + $placementsIn = $input['placements'] ?? []; + if (!is_array($placementsIn)) { + throw new Exception('placements musi by─ç tablic─ů.'); + } + + $stmtSku = $pdo->prepare( + 'SELECT ascii_key FROM sh_menu_items WHERE tenant_id = ? AND category_id = ? AND is_deleted = 0 AND is_active = 1' + ); + $stmtSku->execute([$tenant_id, $cid]); + $allowed = []; + foreach ($stmtSku->fetchAll(PDO::FETCH_ASSOC) as $r) { + $allowed[(string)$r['ascii_key']] = true; + } + + $normalized = []; + foreach ($placementsIn as $row) { + if (!is_array($row)) { + continue; + } + $sku = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($row['sku'] ?? '')); + if ($sku === '' || empty($allowed[$sku])) { + continue; + } + $normalized[] = [ + 'sku' => $sku, + 'x' => isset($row['x']) ? max(0.0, min(1.0, (float)$row['x'])) : 0.5, + 'y' => isset($row['y']) ? max(0.0, min(1.0, (float)$row['y'])) : 0.5, + 'scale' => isset($row['scale']) ? max(0.3, min(3.0, (float)$row['scale'])) : 1.0, + 'z_index' => isset($row['z_index']) ? max(0, min(500, (int)$row['z_index'])) : 40, + ]; + } + + $sid = $ensureCategoryAtelierScene($pdo, (int)$tenant_id, $cid, $atelierHasCategoryCols); + + $stmtSpec = $pdo->prepare('SELECT spec_json FROM sh_atelier_scenes WHERE id = ? AND tenant_id = ? LIMIT 1'); + $stmtSpec->execute([$sid, $tenant_id]); + $specRow = $stmtSpec->fetch(PDO::FETCH_ASSOC); + $spec = []; + if ($specRow && !empty($specRow['spec_json'])) { + $decoded = json_decode((string)$specRow['spec_json'], true); + if (is_array($decoded)) { + $spec = $decoded; + } + } + $spec['version'] = isset($spec['version']) ? (int)$spec['version'] + 1 : 2; + $spec['kind'] = 'category_table'; + $spec['placements'] = $normalized; + $spec['template_key'] = ($tplKey !== '') + ? $tplKey + : (isset($spec['template_key']) ? (string)$spec['template_key'] : 'category_flat_table'); + $spec['updated_via'] = 'menu_studio.category_table_editor'; + + $upd = $pdo->prepare('UPDATE sh_atelier_scenes SET spec_json = ?, version = version + 1 WHERE id = ? AND tenant_id = ?'); + $upd->execute([ + json_encode($spec, JSON_UNESCAPED_UNICODE), + $sid, + $tenant_id, + ]); + + $response['success'] = true; + $response['message'] = 'Zapisano uk┼éad sto┼éu kategorii.'; + $response['data'] = ['sceneId' => $sid, 'placementCount' => count($normalized)]; + break; + // ============================================================================== // 1. POBIERANIE DRZEWA MENU (Z uwzgl─Ödnieniem Macierzy Cenowej) // ============================================================================== case 'get_menu_tree': - $stmtCat = $pdo->prepare("SELECT id, name FROM sh_categories WHERE tenant_id = ? AND is_menu = 1 AND is_deleted = 0 ORDER BY display_order ASC, id ASC"); + // -- Categories -- + $catDelWhere = $catHasIsDeleted ? "AND is_deleted = 0" : ""; + $m022CatCols = $cat022HasLayout ? ", layout_mode, default_composition_profile, category_scene_id" : ""; + if ($catHasVat) { + $stmtCat = $pdo->prepare("SELECT id, name, default_vat_dine_in, default_vat_takeaway, default_vat_delivery {$m022CatCols} FROM sh_categories WHERE tenant_id = ? AND is_menu = 1 $catDelWhere ORDER BY display_order ASC, id ASC"); + } else { + $stmtCat = $pdo->prepare("SELECT id, name {$m022CatCols} FROM sh_categories WHERE tenant_id = ? AND is_menu = 1 $catDelWhere ORDER BY display_order ASC, id ASC"); + } $stmtCat->execute([$tenant_id]); $categoriesRaw = $stmtCat->fetchAll(PDO::FETCH_ASSOC); - // Pobieramy dania bez p┼éaskiej ceny (zgodnie z now─ů architektur─ů) - $stmtItems = $pdo->prepare("SELECT id, category_id, name, ascii_key, is_active, badge_type, is_secret, stock_count, vat_rate_dine_in, kds_station_id, is_locked_by_hq FROM sh_menu_items WHERE tenant_id = ? AND is_deleted = 0 ORDER BY display_order ASC, name ASC"); + // -- Items -- + if ($schemaV2) { + $stmtItems = $pdo->prepare("SELECT id, category_id, name, ascii_key, is_active, badge_type, is_secret, stock_count, vat_rate_dine_in, vat_rate_takeaway, kds_station_id, is_locked_by_hq, image_url, description FROM sh_menu_items WHERE tenant_id = ? AND is_deleted = 0 ORDER BY display_order ASC, name ASC"); + } else { + $stmtItems = $pdo->prepare("SELECT id, category_id, name, ascii_key, is_active, badge_type, is_secret, stock_count, vat_rate AS vat_rate_dine_in, vat_rate AS vat_rate_takeaway, printer_group AS kds_station_id, 0 AS is_locked_by_hq, NULL AS image_url, description FROM sh_menu_items WHERE tenant_id = ? AND is_deleted = 0 ORDER BY display_order ASC, name ASC"); + } $stmtItems->execute([$tenant_id]); $itemsRaw = $stmtItems->fetchAll(PDO::FETCH_ASSOC); - // Pobieramy ca┼é─ů Macierz Cenow─ů dla da┼ä (┼╝eby wklei─ç j─ů do drzewka) - $stmtTiers = $pdo->prepare("SELECT target_sku, channel, price FROM sh_price_tiers WHERE target_type = 'ITEM'"); - $stmtTiers->execute(); - $allTiers = $stmtTiers->fetchAll(PDO::FETCH_ASSOC); + // -- Price tiers (may not exist in legacy) -- + $allTiers = []; + if ($hasPriceTiers) { + $stmtTiers = $pdo->prepare("SELECT target_sku, channel, price FROM sh_price_tiers WHERE target_type = 'ITEM' AND (tenant_id = ? OR tenant_id = 0) ORDER BY target_sku, channel, tenant_id DESC"); + $stmtTiers->execute([$tenant_id]); + $allTiers = $stmtTiers->fetchAll(PDO::FETCH_ASSOC); + } $tiersBySku = []; foreach ($allTiers as $tier) { + $key = $tier['target_sku'] . '|' . $tier['channel']; + if (isset($tiersBySku['_seen'][$key])) continue; + $tiersBySku['_seen'][$key] = true; $tiersBySku[$tier['target_sku']][] = [ 'channel' => $tier['channel'], 'price' => (float)$tier['price'] ]; } + unset($tiersBySku['_seen']); + + // -- Legacy price fallback: if no tiers table, use single `price` column -- + if (!$hasPriceTiers && !$schemaV2) { + $stmtLegacyPrices = $pdo->prepare("SELECT ascii_key, price FROM sh_menu_items WHERE tenant_id = ? AND is_deleted = 0 AND ascii_key IS NOT NULL"); + $stmtLegacyPrices->execute([$tenant_id]); + foreach ($stmtLegacyPrices->fetchAll(PDO::FETCH_ASSOC) as $lp) { + if ($lp['ascii_key']) { + $p = (float)$lp['price']; + $tiersBySku[$lp['ascii_key']] = [ + ['channel' => 'POS', 'price' => $p], + ['channel' => 'Takeaway', 'price' => $p], + ['channel' => 'Delivery', 'price' => $p] + ]; + } + } + } $categories = array_map(function($c) { - return ['id' => (int)$c['id'], 'name' => $c['name']]; - }, $categoriesRaw); - - $categories = array_map(function($c) { - return ['id' => (int)$c['id'], 'name' => $c['name']]; + return [ + 'id' => (int)$c['id'], + 'name' => $c['name'], + 'defaultVatDineIn' => (float)($c['default_vat_dine_in'] ?? 8), + 'defaultVatTakeaway' => (float)($c['default_vat_takeaway'] ?? 5), + 'defaultVatDelivery' => (float)($c['default_vat_delivery'] ?? 5), + // M022: Scene Kit fields + 'layoutMode' => $c['layout_mode'] ?? 'legacy_list', + 'defaultCompositionProfile' => $c['default_composition_profile'] ?? 'static_hero', + 'categorySceneId' => isset($c['category_scene_id']) ? (int)$c['category_scene_id'] : null, + ]; }, $categoriesRaw); $items = array_map(function($i) use ($tiersBySku) { @@ -85,34 +1216,43 @@ try { 'id' => (int)$i['id'], 'categoryId' => (int)$i['category_id'], 'name' => $i['name'], - 'asciiKey' => $i['ascii_key'], + 'asciiKey' => $i['ascii_key'] ?? '', 'isActive' => (bool)$i['is_active'], - 'badgeType' => $i['badge_type'], - 'isSecret' => (bool)$i['is_secret'], - 'stockCount' => (int)$i['stock_count'], - 'vatRate' => (float)$i['vat_rate_dine_in'], - 'kdsStationId' => $i['kds_station_id'], - 'isLockedByHq' => (bool)$i['is_locked_by_hq'], - 'priceTiers' => $tiersBySku[$i['ascii_key']] ?? [] + 'badgeType' => $i['badge_type'] ?? 'none', + 'isSecret' => (bool)($i['is_secret'] ?? 0), + 'stockCount' => (int)($i['stock_count'] ?? -1), + 'vatRate' => (float)($i['vat_rate_dine_in'] ?? 8), + 'kdsStationId' => $i['kds_station_id'] ?? 'NONE', + 'isLockedByHq' => (bool)($i['is_locked_by_hq'] ?? 0), + 'imageUrl' => $i['image_url'] ?? '', + 'description' => $i['description'] ?? '', + 'priceTiers' => $tiersBySku[$i['ascii_key'] ?? ''] ?? [] ]; }, $itemsRaw); - // --- ┼üATKA: POBIERANIE MODYFIKATOR├ôW --- - $stmtMods = $pdo->prepare("SELECT id, name, ascii_key FROM sh_modifier_groups WHERE tenant_id = ? AND is_deleted = 0 ORDER BY id ASC"); - $stmtMods->execute([$tenant_id]); - $modifierGroupsRaw = $stmtMods->fetchAll(PDO::FETCH_ASSOC); + // m021 Asset Studio override ÔÇö hero z sh_asset_links ma priorytet + AssetResolver::injectHeros($pdo, (int)$tenant_id, $items, 'asciiKey', 'imageUrl'); + + // -- Modifier groups -- + try { + $stmtMods = $pdo->prepare("SELECT id, name, ascii_key FROM sh_modifier_groups WHERE tenant_id = ? AND is_deleted = 0 ORDER BY id ASC"); + $stmtMods->execute([$tenant_id]); + $modifierGroupsRaw = $stmtMods->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + $stmtMods = $pdo->prepare("SELECT id, name, '' AS ascii_key FROM sh_modifier_groups WHERE tenant_id = ? ORDER BY id ASC"); + $stmtMods->execute([$tenant_id]); + $modifierGroupsRaw = $stmtMods->fetchAll(PDO::FETCH_ASSOC); + } $modifierGroups = array_map(function($g) { return [ 'id' => (int)$g['id'], 'name' => $g['name'], - 'asciiKey' => $g['ascii_key'] + 'asciiKey' => $g['ascii_key'] ?? '' ]; }, $modifierGroupsRaw); - // --------------------------------------- $response['success'] = true; - // Zwracamy kategorie, dania ORAZ grupy modyfikator├│w! $response['data'] = ['categories' => $categories, 'items' => $items, 'modifierGroups' => $modifierGroups]; $response['message'] = "Pobrano drzewo menu."; break; @@ -124,61 +1264,110 @@ try { $itemId = intval($input['itemId'] ?? 0); if ($itemId <= 0) throw new Exception("Nieprawid┼éowe ID elementu."); - $stmtItem = $pdo->prepare("SELECT id, category_id, name, ascii_key, is_active, vat_rate_dine_in as vat_rate, kds_station_id, is_locked_by_hq, publication_status, valid_from, valid_to, description, image_url, marketing_tags, barcode_ean, parent_sku, allergens_json FROM sh_menu_items WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); + $datCol = $hasDriverActionType ? ", COALESCE(driver_action_type, 'none') AS driver_action_type" : ""; + $cpCol = $mi022HasCompositionProfile ? ", COALESCE(composition_profile, 'static_hero') AS composition_profile" : ""; + if ($schemaV2) { + $stmtItem = $pdo->prepare("SELECT id, category_id, name, ascii_key, `type`, is_active, vat_rate_dine_in, vat_rate_takeaway, kds_station_id, printer_group, is_locked_by_hq, publication_status, valid_from, valid_to, description, image_url, marketing_tags, barcode_ean, parent_sku, allergens_json, badge_type, is_secret, stock_count, display_order, plu_code, available_days, available_start, available_end{$datCol}{$cpCol} FROM sh_menu_items WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); + } else { + $stmtItem = $pdo->prepare("SELECT id, category_id, name, ascii_key, `type`, is_active, price, vat_rate AS vat_rate_dine_in, vat_rate AS vat_rate_takeaway, printer_group, printer_group AS kds_station_id, 0 AS is_locked_by_hq, 'Draft' AS publication_status, NULL AS valid_from, NULL AS valid_to, description, NULL AS image_url, tags AS marketing_tags, NULL AS barcode_ean, NULL AS parent_sku, NULL AS allergens_json, badge_type, is_secret, stock_count, display_order, plu_code, available_days, available_start, available_end, 'none' AS driver_action_type, 'static_hero' AS composition_profile FROM sh_menu_items WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); + } $stmtItem->execute([$itemId, $tenant_id]); $item = $stmtItem->fetch(PDO::FETCH_ASSOC); if (!$item) throw new Exception("Nie znaleziono dania."); - $itemData = $item; - $item['barcodeEan'] = $itemData['barcode_ean'] ?? ''; - $item['parentSku'] = $itemData['parent_sku'] ?? ''; - $allergensRaw = $itemData['allergens_json'] ?? '[]'; - $item['allergens'] = is_string($allergensRaw) ? json_decode($allergensRaw, true) : []; - if (!is_array($item['allergens'])) { - $item['allergens'] = []; - } - - // Pobieranie cen z Macierzy dla tego konkretnego dania - $stmtPrice = $pdo->prepare("SELECT channel, price FROM sh_price_tiers WHERE target_type = 'ITEM' AND target_sku = ?"); - $stmtPrice->execute([$item['ascii_key']]); - $prices = $stmtPrice->fetchAll(PDO::FETCH_ASSOC); + $allergensRaw = $item['allergens_json'] ?? '[]'; + $allergens = is_string($allergensRaw) ? json_decode($allergensRaw, true) : []; + if (!is_array($allergens)) $allergens = []; + // Price tiers $priceMatrix = []; - $priceTiers = []; - foreach ($prices as $p) { - $priceMatrix[$p['channel']] = (float)$p['price']; - $priceTiers[] = ['channel' => $p['channel'], 'price' => (float)$p['price']]; + $priceTiersOut = []; + if ($hasPriceTiers) { + $stmtPrice = $pdo->prepare("SELECT channel, price FROM sh_price_tiers WHERE target_type = 'ITEM' AND target_sku = ? AND (tenant_id = ? OR tenant_id = 0) ORDER BY channel, tenant_id DESC"); + $stmtPrice->execute([$item['ascii_key'] ?? '', $tenant_id]); + $prices = $stmtPrice->fetchAll(PDO::FETCH_ASSOC); + foreach ($prices as $p) { + if (!isset($priceMatrix[$p['channel']])) { + $priceMatrix[$p['channel']] = (float)$p['price']; + $priceTiersOut[] = ['channel' => $p['channel'], 'price' => (float)$p['price']]; + } + } + } elseif (isset($item['price'])) { + $legacyPrice = (float)$item['price']; + $priceMatrix = ['POS' => $legacyPrice, 'Takeaway' => $legacyPrice, 'Delivery' => $legacyPrice]; + $priceTiersOut = [ + ['channel' => 'POS', 'price' => $legacyPrice], + ['channel' => 'Takeaway', 'price' => $legacyPrice], + ['channel' => 'Delivery', 'price' => $legacyPrice] + ]; + } + + // Modifier assignments + $modGroupIds = []; + try { + $stmtMods = $pdo->prepare("SELECT group_id FROM sh_item_modifiers WHERE item_id = ?"); + $stmtMods->execute([$itemId]); + $modGroupIds = array_map('intval', $stmtMods->fetchAll(PDO::FETCH_COLUMN) ?: []); + } catch (PDOException $e) { + $modGroupIds = []; } - // Pobranie przypisanych modyfikator├│w - $stmtMods = $pdo->prepare("SELECT group_id FROM sh_item_modifiers WHERE item_id = ?"); - $stmtMods->execute([$itemId]); - $modifierGroupIds = $stmtMods->fetchAll(PDO::FETCH_COLUMN) ?: []; - $item['modifierGroupIds'] = array_map('intval', $modifierGroupIds); + // m021 Asset Studio override ÔÇö pojedynczy item + $resolvedHero = AssetResolver::resolveHero($pdo, (int)$tenant_id, (string)($item['ascii_key'] ?? '')); + $finalImageUrl = $resolvedHero['url'] ?? ($item['image_url'] ?? ''); + + // M022: scene meta (has_scene + composition_profile) + $sceneMeta022 = ['hasScene' => false, 'sceneId' => null]; + if ($hasSceneTemplates && !empty($item['ascii_key'])) { + try { + $chkScene = $pdo->prepare("SELECT id FROM sh_atelier_scenes WHERE tenant_id = ? AND item_sku = ? LIMIT 1"); + $chkScene->execute([$tenant_id, $item['ascii_key']]); + $sceneRow = $chkScene->fetch(PDO::FETCH_ASSOC); + if ($sceneRow) { + $sceneMeta022 = ['hasScene' => true, 'sceneId' => (int)$sceneRow['id']]; + } + } catch (PDOException $e) {} + } $response['success'] = true; $response['data'] = [ 'id' => (int)$item['id'], 'categoryId' => (int)$item['category_id'], 'name' => $item['name'], - 'asciiKey' => $item['ascii_key'], + 'asciiKey' => $item['ascii_key'] ?? '', + 'type' => $item['type'] ?? 'standard', 'isActive' => (bool)$item['is_active'], - 'vatRate' => (float)$item['vat_rate'], - 'kdsStationId' => $item['kds_station_id'], - 'isLockedByHq' => (bool)$item['is_locked_by_hq'], - 'publicationStatus' => $item['publication_status'], - 'validFrom' => $item['valid_from'], - 'validTo' => $item['valid_to'], - 'description' => $item['description'], - 'imageUrl' => $item['image_url'], - 'marketingTags' => $item['marketing_tags'], - 'modifierGroupIds' => $item['modifierGroupIds'], - 'barcodeEan' => $item['barcodeEan'], - 'parentSku' => $item['parentSku'], - 'allergens' => $item['allergens'], - 'priceMatrix' => $priceMatrix, // Kompatybilno┼Ť─ç z formularzem - 'priceTiers' => $priceTiers // Czysta architektura + 'vatRateDineIn' => (float)($item['vat_rate_dine_in'] ?? 8), + 'vatRateTakeaway' => (float)($item['vat_rate_takeaway'] ?? 5), + 'kdsStationId' => $item['kds_station_id'] ?? 'NONE', + 'printerGroup' => $item['printer_group'] ?? 'KITCHEN_1', + 'isLockedByHq' => (bool)($item['is_locked_by_hq'] ?? 0), + 'publicationStatus' => $item['publication_status'] ?? 'Draft', + 'validFrom' => $item['valid_from'] ?? null, + 'validTo' => $item['valid_to'] ?? null, + 'description' => $item['description'] ?? '', + 'imageUrl' => $finalImageUrl, + // M022: composition profile + scene meta + 'compositionProfile' => $item['composition_profile'] ?? 'static_hero', + 'hasScene' => $sceneMeta022['hasScene'], + 'sceneId' => $sceneMeta022['sceneId'], + 'marketingTags' => $item['marketing_tags'] ?? '', + 'badgeType' => $item['badge_type'] ?? 'none', + 'isSecret' => (bool)($item['is_secret'] ?? 0), + 'stockCount' => (int)($item['stock_count'] ?? -1), + 'displayOrder' => (int)($item['display_order'] ?? 0), + 'pluCode' => $item['plu_code'] ?? '', + 'availableDays' => $item['available_days'] ?? '1,2,3,4,5,6,7', + 'availableStart' => $item['available_start'] ?? null, + 'availableEnd' => $item['available_end'] ?? null, + 'modifierGroupIds' => $modGroupIds, + 'barcodeEan' => $item['barcode_ean'] ?? '', + 'parentSku' => $item['parent_sku'] ?? '', + 'allergens' => $allergens, + 'driverActionType' => $item['driver_action_type'] ?? 'none', + 'priceMatrix' => $priceMatrix, + 'priceTiers' => $priceTiersOut ]; $response['message'] = "Pobrano szczeg├│┼éy dania."; break; @@ -193,11 +1382,22 @@ try { $name = trim($input['name'] ?? ''); $asciiKey = preg_replace('/[^a-zA-Z0-9_-]/', '', $input['asciiKey'] ?? ''); - $vatRate = floatval($input['vatRate'] ?? 0); + $vatRateDineIn = floatval($input['vatRateDineIn'] ?? $input['vatRate'] ?? 8); + $vatRateTakeaway = floatval($input['vatRateTakeaway'] ?? $input['vatRate'] ?? 5); $kdsStationId = preg_replace('/[^a-zA-Z0-9_-]/', '', $input['kdsStationId'] ?? 'NONE'); $isActive = isset($input['isActive']) ? (int)filter_var($input['isActive'], FILTER_VALIDATE_BOOLEAN) : 1; - // Nowe pola temporal i marketing + $itemType = in_array($input['type'] ?? '', ['standard', 'half_half']) ? $input['type'] : 'standard'; + $printerGroup = preg_replace('/[^a-zA-Z0-9_-]/', '', $input['printerGroup'] ?? 'KITCHEN_1'); + $pluCode = $toNull(preg_replace('/[^a-zA-Z0-9_-]/', '', $input['pluCode'] ?? '')); + $displayOrder = intval($input['displayOrder'] ?? 0); + $stockCount = intval($input['stockCount'] ?? -1); + $badgeType = in_array($input['badgeType'] ?? '', ['none','new','promo','bestseller','hot']) ? $input['badgeType'] : 'none'; + $isSecret = !empty($input['isSecret']) ? 1 : 0; + $availableDays = preg_replace('/[^0-9,]/', '', $input['availableDays'] ?? '1,2,3,4,5,6,7'); + $availableStart = $toNull($input['availableStart'] ?? null); + $availableEnd = $toNull($input['availableEnd'] ?? null); + $pubStatus = in_array($input['publicationStatus'] ?? '', ['Draft', 'Live', 'Archived']) ? $input['publicationStatus'] : 'Draft'; $validFrom = $toNull($input['validFrom'] ?? null); $validTo = $toNull($input['validTo'] ?? null); @@ -213,6 +1413,14 @@ try { $allergensRaw = $input['allergens'] ?? []; $allergensJson = is_array($allergensRaw) ? json_encode($allergensRaw) : '[]'; + + // M022: composition_profile + $compositionProfile = trim($input['compositionProfile'] ?? 'static_hero'); + if ($compositionProfile === '') $compositionProfile = 'static_hero'; + + $driverActionType = in_array($input['driverActionType'] ?? '', ['none','pack_cold','pack_separate','check_id'], true) + ? $input['driverActionType'] + : 'none'; $priceTiers = $input['priceTiers'] ?? []; @@ -223,34 +1431,84 @@ try { $pdo->beginTransaction(); try { - if ($action === 'add_item') { - $stmt = $pdo->prepare("INSERT INTO sh_menu_items (tenant_id, category_id, name, ascii_key, is_active, vat_rate_dine_in, vat_rate_takeaway, kds_station_id, publication_status, valid_from, valid_to, description, image_url, marketing_tags, barcode_ean, parent_sku, allergens_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); - $stmt->execute([$tenant_id, $categoryId, $name, $asciiKey, $isActive, $vatRate, $vatRate, $kdsStationId, $pubStatus, $validFrom, $validTo, $description, $imageUrl, $marketingTags, $barcodeEan, $parentSku, $allergensJson]); - $itemId = $pdo->lastInsertId(); + if ($schemaV2) { + // ---- V2 schema: all new columns ---- + if ($action === 'add_item') { + $cols = "tenant_id, category_id, name, ascii_key, `type`, is_active, vat_rate_dine_in, vat_rate_takeaway, kds_station_id, printer_group, publication_status, valid_from, valid_to, description, image_url, marketing_tags, badge_type, is_secret, stock_count, display_order, plu_code, available_days, available_start, available_end, barcode_ean, parent_sku, allergens_json"; + $vals = "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?"; + $params = [$tenant_id, $categoryId, $name, $asciiKey, $itemType, $isActive, $vatRateDineIn, $vatRateTakeaway, $kdsStationId, $printerGroup, $pubStatus, $validFrom, $validTo, $description, $imageUrl, $marketingTags, $badgeType, $isSecret, $stockCount, $displayOrder, $pluCode, $availableDays, $availableStart, $availableEnd, $barcodeEan, $parentSku, $allergensJson]; + if ($hasDriverActionType) { + $cols .= ", driver_action_type"; + $vals .= ", ?"; + $params[] = $driverActionType; + } + if ($mi022HasCompositionProfile) { + $cols .= ", composition_profile"; + $vals .= ", ?"; + $params[] = $compositionProfile; + } + $stmt = $pdo->prepare("INSERT INTO sh_menu_items ($cols) VALUES ($vals)"); + $stmt->execute($params); + $itemId = $pdo->lastInsertId(); + } else { + $setCols = "name = ?, ascii_key = ?, category_id = ?, `type` = ?, is_active = ?, vat_rate_dine_in = ?, vat_rate_takeaway = ?, kds_station_id = ?, printer_group = ?, publication_status = ?, valid_from = ?, valid_to = ?, description = ?, image_url = ?, marketing_tags = ?, badge_type = ?, is_secret = ?, stock_count = ?, display_order = ?, plu_code = ?, available_days = ?, available_start = ?, available_end = ?, barcode_ean = ?, parent_sku = ?, allergens_json = ?"; + $params = [$name, $asciiKey, $categoryId, $itemType, $isActive, $vatRateDineIn, $vatRateTakeaway, $kdsStationId, $printerGroup, $pubStatus, $validFrom, $validTo, $description, $imageUrl, $marketingTags, $badgeType, $isSecret, $stockCount, $displayOrder, $pluCode, $availableDays, $availableStart, $availableEnd, $barcodeEan, $parentSku, $allergensJson]; + if ($hasDriverActionType) { + $setCols .= ", driver_action_type = ?"; + $params[] = $driverActionType; + } + if ($mi022HasCompositionProfile) { + $setCols .= ", composition_profile = ?"; + $params[] = $compositionProfile; + } + $setCols .= ", updated_at = NOW()"; + $params[] = $itemId; + $params[] = $tenant_id; + $stmt = $pdo->prepare("UPDATE sh_menu_items SET $setCols WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); + $stmt->execute($params); + } } else { - $stmt = $pdo->prepare("UPDATE sh_menu_items SET name = ?, ascii_key = ?, category_id = ?, is_active = ?, vat_rate_dine_in = ?, vat_rate_takeaway = ?, kds_station_id = ?, publication_status = ?, valid_from = ?, valid_to = ?, description = ?, image_url = ?, marketing_tags = ?, barcode_ean = ?, parent_sku = ?, allergens_json = ?, updated_at = NOW() WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); - $stmt->execute([$name, $asciiKey, $categoryId, $isActive, $vatRate, $vatRate, $kdsStationId, $pubStatus, $validFrom, $validTo, $description, $imageUrl, $marketingTags, $barcodeEan, $parentSku, $allergensJson, $itemId, $tenant_id]); - } + // ---- Legacy schema: map to old columns ---- + $legacyPrice = 0; + foreach ($priceTiers as $t) { + if (($t['channel'] ?? '') === 'POS') { $legacyPrice = floatval($t['price'] ?? 0); break; } + } + if ($legacyPrice == 0 && !empty($priceTiers)) $legacyPrice = floatval($priceTiers[0]['price'] ?? 0); - // Bezpieczny Upsert do Macierzy Cenowej (sh_price_tiers) - $stmtTier = $pdo->prepare("INSERT INTO sh_price_tiers (target_type, target_sku, channel, price) VALUES ('ITEM', ?, ?, ?) ON DUPLICATE KEY UPDATE price = ?"); - foreach ($priceTiers as $tier) { - $channel = $tier['channel'] ?? 'POS'; - $price = floatval($tier['price'] ?? 0); - $stmtTier->execute([$asciiKey, $channel, $price, $price]); + if ($action === 'add_item') { + $stmt = $pdo->prepare("INSERT INTO sh_menu_items (tenant_id, category_id, name, ascii_key, `type`, is_active, price, vat_rate, printer_group, plu_code, description, display_order, available_days, available_start, available_end, stock_count, badge_type, is_secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + $stmt->execute([$tenant_id, $categoryId, $name, $asciiKey, $itemType, $isActive, $legacyPrice, $vatRateDineIn, $printerGroup, $pluCode, $description, $displayOrder, $availableDays, $availableStart, $availableEnd, $stockCount, $badgeType, $isSecret]); + $itemId = $pdo->lastInsertId(); + } else { + $stmt = $pdo->prepare("UPDATE sh_menu_items SET name = ?, ascii_key = ?, category_id = ?, `type` = ?, is_active = ?, price = ?, vat_rate = ?, printer_group = ?, plu_code = ?, description = ?, display_order = ?, available_days = ?, available_start = ?, available_end = ?, stock_count = ?, badge_type = ?, is_secret = ? WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); + $stmt->execute([$name, $asciiKey, $categoryId, $itemType, $isActive, $legacyPrice, $vatRateDineIn, $printerGroup, $pluCode, $description, $displayOrder, $availableDays, $availableStart, $availableEnd, $stockCount, $badgeType, $isSecret, $itemId, $tenant_id]); + } } - // Wyczy┼Ť─ç stare przypisania - $stmtDeleteMods = $pdo->prepare("DELETE FROM sh_item_modifiers WHERE item_id = ?"); - $stmtDeleteMods->execute([$itemId]); + // Price tiers (skip if table doesn't exist) + if ($hasPriceTiers) { + $stmtTier = $pdo->prepare("INSERT INTO sh_price_tiers (tenant_id, target_type, target_sku, channel, price) VALUES (?, 'ITEM', ?, ?, ?) ON DUPLICATE KEY UPDATE price = VALUES(price)"); + foreach ($priceTiers as $tier) { + $channel = $tier['channel'] ?? 'POS'; + $price = floatval($tier['price'] ?? 0); + $stmtTier->execute([$tenant_id, $asciiKey, $channel, $price]); + } + } - // Zapisz nowe przypisania z payloadu - $modifierGroupIds = $input['modifierGroupIds'] ?? []; - if (!empty($modifierGroupIds) && is_array($modifierGroupIds)) { - $stmtInsertMod = $pdo->prepare("INSERT INTO sh_item_modifiers (item_id, group_id) VALUES (?, ?)"); - foreach ($modifierGroupIds as $groupId) { - $stmtInsertMod->execute([$itemId, intval($groupId)]); + // Modifier assignments + try { + $stmtDeleteMods = $pdo->prepare("DELETE FROM sh_item_modifiers WHERE item_id = ?"); + $stmtDeleteMods->execute([$itemId]); + + $modifierGroupIds = $input['modifierGroupIds'] ?? []; + if (!empty($modifierGroupIds) && is_array($modifierGroupIds)) { + $stmtInsertMod = $pdo->prepare("INSERT INTO sh_item_modifiers (item_id, group_id) VALUES (?, ?)"); + foreach ($modifierGroupIds as $groupId) { + $stmtInsertMod->execute([$itemId, intval($groupId)]); + } } + } catch (PDOException $e) { + // sh_item_modifiers might not exist ÔÇö skip gracefully } $pdo->commit(); @@ -280,9 +1538,11 @@ try { $params = []; $updates = []; - // Standardowe pola z Bulk $kdsGroup = preg_replace('/[^a-zA-Z0-9_-]/', '', $input['kdsGroup'] ?? ''); - if ($kdsGroup !== '') { $updates[] = "kds_station_id = ?"; $params[] = $kdsGroup; } + if ($kdsGroup !== '') { + $col = $schemaV2 ? "kds_station_id" : "printer_group"; + $updates[] = "$col = ?"; $params[] = $kdsGroup; + } $badgeType = preg_replace('/[^a-zA-Z0-9_-]/', '', $input['badgeType'] ?? ''); if ($badgeType !== '') { $updates[] = "badge_type = ?"; $params[] = $badgeType; } @@ -291,8 +1551,7 @@ try { $updates[] = "is_secret = ?"; $params[] = filter_var($input['isSecret'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } - // Obs┼éuga Temporal Tables (Harmonogram Masowy) - if (!empty($input['temporalPublicationPatch']) && !empty($input['temporalPublicationPatch']['apply'])) { + if ($schemaV2 && !empty($input['temporalPublicationPatch']) && !empty($input['temporalPublicationPatch']['apply'])) { $patch = $input['temporalPublicationPatch']; if ($patch['status'] !== 'NO_CHANGE' && in_array($patch['status'], ['Draft', 'Live', 'Archived'])) { $updates[] = "publication_status = ?"; $params[] = $patch['status']; @@ -306,29 +1565,29 @@ try { } if (!empty($updates)) { - $sql = "UPDATE sh_menu_items SET " . implode(', ', $updates) . ", updated_at = NOW() WHERE tenant_id = ? AND is_deleted = 0 AND id IN ($placeholders)"; + $setClause = implode(', ', $updates); + if ($schemaV2) $setClause .= ", updated_at = NOW()"; + $sql = "UPDATE sh_menu_items SET $setClause WHERE tenant_id = ? AND is_deleted = 0 AND id IN ($placeholders)"; $finalParams = array_merge($params, [$tenant_id], $cleanIds); $stmtUpdate = $pdo->prepare($sql); $stmtUpdate->execute($finalParams); } - // Obs┼éuga Omnichannel (Zarz─ůdzanie wybranym kana┼éem) - if (!empty($input['omnichannelPricePatch']) && !empty($input['omnichannelPricePatch']['apply'])) { + if ($hasPriceTiers && !empty($input['omnichannelPricePatch']) && !empty($input['omnichannelPricePatch']['apply'])) { $patch = $input['omnichannelPricePatch']; $targetChannel = $patch['targetChannel']; $opType = $patch['operationType']; $opValue = (float)$patch['operationValue']; - // Wyci─ůgamy SKU zaznaczonych da┼ä, ┼╝eby wiedzie─ç w co uderzy─ç w Macierzy $stmtSku = $pdo->prepare("SELECT ascii_key FROM sh_menu_items WHERE tenant_id = ? AND is_deleted = 0 AND id IN ($placeholders)"); $stmtSku->execute(array_merge([$tenant_id], $cleanIds)); $skus = $stmtSku->fetchAll(PDO::FETCH_COLUMN); - $stmtCurrentPrice = $pdo->prepare("SELECT price FROM sh_price_tiers WHERE target_type='ITEM' AND target_sku=? AND channel=?"); - $stmtUpsertPrice = $pdo->prepare("INSERT INTO sh_price_tiers (target_type, target_sku, channel, price) VALUES ('ITEM', ?, ?, ?) ON DUPLICATE KEY UPDATE price = ?"); + $stmtCurrentPrice = $pdo->prepare("SELECT price FROM sh_price_tiers WHERE target_type='ITEM' AND target_sku=? AND channel=? AND (tenant_id = ? OR tenant_id = 0) ORDER BY tenant_id DESC LIMIT 1"); + $stmtUpsertPrice = $pdo->prepare("INSERT INTO sh_price_tiers (tenant_id, target_type, target_sku, channel, price) VALUES (?, 'ITEM', ?, ?, ?) ON DUPLICATE KEY UPDATE price = VALUES(price)"); foreach ($skus as $sku) { - $stmtCurrentPrice->execute([$sku, $targetChannel]); + $stmtCurrentPrice->execute([$sku, $targetChannel, $tenant_id]); $row = $stmtCurrentPrice->fetch(PDO::FETCH_ASSOC); $currentPrice = $row ? (float)$row['price'] : 0.00; @@ -337,10 +1596,8 @@ try { elseif ($opType === 'increase_percent') $newPrice = $currentPrice * (1 + ($opValue / 100)); elseif ($opType === 'increase_pln') $newPrice = $currentPrice + $opValue; - // Tarcza poni┼╝ej zera if ($newPrice < 0) $newPrice = 0; - - $stmtUpsertPrice->execute([$sku, $targetChannel, $newPrice, $newPrice]); + $stmtUpsertPrice->execute([$tenant_id, $sku, $targetChannel, $newPrice]); } } @@ -353,6 +1610,89 @@ try { } break; + // ============================================================================== + // 4b. SZYBKI ZAPIS POJEDYNCZEGO MODYFIKATORA (draft z Studio ÔÇö zamiast api_modifiers.php) + // ============================================================================== + case 'save_modifier_quick': + $groupName = trim($input['groupName'] ?? ''); + $name = trim($input['name'] ?? ''); + $asciiKey = strtoupper(preg_replace('/[^a-zA-Z0-9_]/', '', $input['asciiKey'] ?? '')); + $priceTiers = $input['priceTiers'] ?? []; + $wh = $input['warehouseLink'] ?? []; + + if ($name === '') { + throw new Exception('Nazwa modyfikatora jest wymagana.'); + } + if ($asciiKey === '') { + throw new Exception('Klucz systemowy (asciiKey) jest wymagany.'); + } + if ($groupName === '') { + throw new Exception('Nazwa grupy jest wymagana.'); + } + + $actionType = in_array($wh['actionType'] ?? '', ['ADD', 'REMOVE', 'NONE'], true) + ? $wh['actionType'] + : 'NONE'; + $warehouseSku = ($actionType !== 'NONE' && !empty($wh['warehouseSku'])) + ? trim($wh['warehouseSku']) + : null; + $linkedQty = ($actionType === 'ADD') ? (float)($wh['quantity'] ?? 0) : 0.0; + $linkedWaste = ($actionType === 'ADD') ? (float)($wh['wastePercent'] ?? 0) : 0.0; + + try { + $stmtFindGroup = $pdo->prepare("SELECT id FROM sh_modifier_groups WHERE tenant_id = ? AND name = ? AND is_deleted = 0 LIMIT 1"); + $stmtFindGroup->execute([$tenant_id, $groupName]); + } catch (PDOException $e) { + $stmtFindGroup = $pdo->prepare("SELECT id FROM sh_modifier_groups WHERE tenant_id = ? AND name = ? LIMIT 1"); + $stmtFindGroup->execute([$tenant_id, $groupName]); + } + $existingGroup = $stmtFindGroup->fetch(PDO::FETCH_ASSOC); + + if ($existingGroup) { + $groupId = (int)$existingGroup['id']; + } else { + $stmtNewGroup = $pdo->prepare( + "INSERT INTO sh_modifier_groups (tenant_id, name, min_selection, max_selection) + VALUES (?, ?, 0, 10)" + ); + $stmtNewGroup->execute([$tenant_id, $groupName]); + $groupId = (int)$pdo->lastInsertId(); + } + + $newModifierId = 0; + try { + $stmtMod = $pdo->prepare( + "INSERT INTO sh_modifiers + (group_id, name, ascii_key, action_type, + linked_warehouse_sku, linked_quantity, linked_waste_percent) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $stmtMod->execute([$groupId, $name, $asciiKey, $actionType, $warehouseSku, $linkedQty, $linkedWaste]); + $newModifierId = (int)$pdo->lastInsertId(); + } catch (PDOException $e) { + // sh_modifiers table may not exist in legacy + } + + if ($hasPriceTiers) { + $stmtTier = $pdo->prepare( + "INSERT INTO sh_price_tiers (tenant_id, target_type, target_sku, channel, price) + VALUES (?, 'MODIFIER', ?, ?, ?) + ON DUPLICATE KEY UPDATE price = VALUES(price)" + ); + $allowedChannels = ['POS', 'Takeaway', 'Delivery']; + foreach ($priceTiers as $tier) { + $channel = $tier['channel'] ?? ''; + if (!in_array($channel, $allowedChannels, true)) continue; + $priceVal = (float)($tier['price'] ?? 0); + $stmtTier->execute([$tenant_id, $asciiKey, $channel, $priceVal]); + } + } + + $response['success'] = true; + $response['message'] = 'Modyfikator zapisany pomy┼Ťlnie.'; + $response['data'] = ['id' => $newModifierId, 'groupId' => $groupId]; + break; + // ============================================================================== // 5. ZAPIS GRUPY MODYFIKATOR├ôW I OPCJI (KSeF + Macierz) // ============================================================================== @@ -376,65 +1716,113 @@ try { try { if ($groupId > 0) { - $stmtCheck = $pdo->prepare("SELECT id FROM sh_modifier_groups WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); - $stmtCheck->execute([$groupId, $tenant_id]); + try { + $stmtCheck = $pdo->prepare("SELECT id FROM sh_modifier_groups WHERE id = ? AND tenant_id = ? AND is_deleted = 0"); + $stmtCheck->execute([$groupId, $tenant_id]); + } catch (PDOException $e) { + $stmtCheck = $pdo->prepare("SELECT id FROM sh_modifier_groups WHERE id = ? AND tenant_id = ?"); + $stmtCheck->execute([$groupId, $tenant_id]); + } if (!$stmtCheck->fetch()) throw new Exception("Grupa nie istnieje."); - $stmt = $pdo->prepare("UPDATE sh_modifier_groups SET name = ?, min_selection = ?, max_selection = ?, free_limit = ?, allow_multi_qty = ?, publication_status = ?, valid_from = ?, valid_to = ? WHERE id = ? AND tenant_id = ?"); - $stmt->execute([$name, $minSel, $maxSel, $freeLimit, $allowMulti, $pubStatus, $validFrom, $validTo, $groupId, $tenant_id]); + try { + $stmt = $pdo->prepare("UPDATE sh_modifier_groups SET name = ?, min_selection = ?, max_selection = ?, free_limit = ?, allow_multi_qty = ?, publication_status = ?, valid_from = ?, valid_to = ? WHERE id = ? AND tenant_id = ?"); + $stmt->execute([$name, $minSel, $maxSel, $freeLimit, $allowMulti, $pubStatus, $validFrom, $validTo, $groupId, $tenant_id]); + } catch (PDOException $e) { + $stmt = $pdo->prepare("UPDATE sh_modifier_groups SET name = ?, min_selection = ?, max_selection = ? WHERE id = ? AND tenant_id = ?"); + $stmt->execute([$name, $minSel, $maxSel, $groupId, $tenant_id]); + } } else { - $stmt = $pdo->prepare("INSERT INTO sh_modifier_groups (tenant_id, name, ascii_key, min_selection, max_selection, free_limit, allow_multi_qty, publication_status, valid_from, valid_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); - $stmt->execute([$tenant_id, $name, $groupAsciiKey, $minSel, $maxSel, $freeLimit, $allowMulti, $pubStatus, $validFrom, $validTo]); + try { + $stmt = $pdo->prepare("INSERT INTO sh_modifier_groups (tenant_id, name, ascii_key, min_selection, max_selection, free_limit, allow_multi_qty, publication_status, valid_from, valid_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + $stmt->execute([$tenant_id, $name, $groupAsciiKey, $minSel, $maxSel, $freeLimit, $allowMulti, $pubStatus, $validFrom, $validTo]); + } catch (PDOException $e) { + $stmt = $pdo->prepare("INSERT INTO sh_modifier_groups (tenant_id, name, min_selection, max_selection) VALUES (?, ?, ?, ?)"); + $stmt->execute([$tenant_id, $name, $minSel, $maxSel]); + } $groupId = $pdo->lastInsertId(); } + // sh_modifiers + sh_price_tiers ÔÇö only available on V2 schema $savedOptionIds = []; - $stmtInsertOpt = $pdo->prepare("INSERT INTO sh_modifiers (group_id, name, ascii_key, action_type, linked_warehouse_sku, linked_quantity, is_default) VALUES (?, ?, ?, ?, ?, ?, ?)"); - $stmtUpdateOpt = $pdo->prepare("UPDATE sh_modifiers SET name = ?, action_type = ?, linked_warehouse_sku = ?, linked_quantity = ?, is_default = ?, is_deleted = 0 WHERE id = ? AND group_id = ?"); - $stmtTier = $pdo->prepare("INSERT INTO sh_price_tiers (target_type, target_sku, channel, price) VALUES ('MODIFIER', ?, ?, ?) ON DUPLICATE KEY UPDATE price = ?"); - - foreach ($options as $opt) { - $optId = intval($opt['id'] ?? 0); - $optName = trim($opt['name'] ?? ''); - $optAsciiKey = preg_replace('/[^a-zA-Z0-9_-]/', '', $opt['asciiKey'] ?? ''); - $actionType = in_array($opt['actionType'] ?? '', ['NONE','ADD','REMOVE']) ? $opt['actionType'] : 'NONE'; - $linkedSku = $toNull($opt['linkedWarehouseSku'] ?? null); - $linkedQty = (float)($opt['linkedQuantity'] ?? 0); - $isDefault = !empty($opt['isDefault']) ? 1 : 0; - $priceTiers = $opt['priceTiers'] ?? []; - - if (empty($optName) || empty($optAsciiKey)) continue; - - if ($optId > 0) { - $stmtUpdateOpt->execute([$optName, $actionType, $linkedSku, $linkedQty, $isDefault, $optId, $groupId]); - $savedOptionIds[] = $optId; - } else { - $stmtInsertOpt->execute([$groupId, $optName, $optAsciiKey, $actionType, $linkedSku, $linkedQty, $isDefault]); - $savedOptionIds[] = $pdo->lastInsertId(); + $hasModifiersTable = false; + try { + $pdo->query("SELECT 1 FROM sh_modifiers LIMIT 0"); + $hasModifiersTable = true; + } catch (PDOException $e) {} + + if ($hasModifiersTable) { + $stmtInsertOpt = $pdo->prepare("INSERT INTO sh_modifiers (group_id, name, ascii_key, action_type, linked_warehouse_sku, linked_quantity, is_default) VALUES (?, ?, ?, ?, ?, ?, ?)"); + $stmtUpdateOpt = $pdo->prepare("UPDATE sh_modifiers SET name = ?, action_type = ?, linked_warehouse_sku = ?, linked_quantity = ?, is_default = ?, is_deleted = 0 WHERE id = ? AND group_id = ?"); + + foreach ($options as $opt) { + $optId = intval($opt['id'] ?? 0); + $optName = trim($opt['name'] ?? ''); + $optAsciiKey = preg_replace('/[^a-zA-Z0-9_-]/', '', $opt['asciiKey'] ?? ''); + $actionType = in_array($opt['actionType'] ?? '', ['NONE','ADD','REMOVE']) ? $opt['actionType'] : 'NONE'; + $linkedSku = $toNull($opt['linkedWarehouseSku'] ?? null); + $linkedQty = (float)($opt['linkedQuantity'] ?? 0); + $isDefault = !empty($opt['isDefault']) ? 1 : 0; + $optPriceTiers = $opt['priceTiers'] ?? []; + + if (empty($optName) || empty($optAsciiKey)) continue; + + if ($optId > 0) { + $stmtUpdateOpt->execute([$optName, $actionType, $linkedSku, $linkedQty, $isDefault, $optId, $groupId]); + $savedOptionIds[] = $optId; + $resolvedModId = $optId; + } else { + $stmtInsertOpt->execute([$groupId, $optName, $optAsciiKey, $actionType, $linkedSku, $linkedQty, $isDefault]); + $resolvedModId = (int)$pdo->lastInsertId(); + $savedOptionIds[] = $resolvedModId; + } + + if ($hasModifierVisualImpact) { + $hvi = array_key_exists('hasVisualImpact', $opt) + ? (!empty($opt['hasVisualImpact']) ? 1 : 0) + : 1; + $pdo->prepare('UPDATE sh_modifiers SET has_visual_impact = ? WHERE id = ?') + ->execute([$hvi, $resolvedModId]); + } + + $la = isset($opt['layerTopDownAssetId']) ? (int)$opt['layerTopDownAssetId'] : 0; + $ha = isset($opt['modifierHeroAssetId']) ? (int)$opt['modifierHeroAssetId'] : 0; + try { + $syncModifierVisualAssetLinks( + $pdo, + $tenant_id, + $optAsciiKey, + $la > 0 ? $la : null, + $ha > 0 ? $ha : null + ); + } catch (\Throwable $e) { + // Brak sh_asset_links / m021 ÔÇö reszta grupy zapisuje si─Ö normalnie. + } + + if ($hasPriceTiers) { + $stmtTier = $pdo->prepare("INSERT INTO sh_price_tiers (tenant_id, target_type, target_sku, channel, price) VALUES (?, 'MODIFIER', ?, ?, ?) ON DUPLICATE KEY UPDATE price = VALUES(price)"); + foreach ($optPriceTiers as $tier) { + $channel = $tier['channel'] ?? 'POS'; + $price = floatval($tier['price'] ?? 0); + $stmtTier->execute([$tenant_id, $optAsciiKey, $channel, $price]); + } + } } - // Zapis cen do Macierzy - foreach ($priceTiers as $tier) { - $channel = $tier['channel'] ?? 'POS'; - $price = floatval($tier['price'] ?? 0); - $stmtTier->execute([$optAsciiKey, $channel, $price, $price]); + if (!empty($savedOptionIds)) { + $placeholdersOpt = implode(',', array_fill(0, count($savedOptionIds), '?')); + $stmtDelOpt = $pdo->prepare("UPDATE sh_modifiers SET is_deleted = 1 WHERE group_id = ? AND id NOT IN ($placeholdersOpt)"); + $stmtDelOpt->execute(array_merge([$groupId], $savedOptionIds)); + } else { + $stmtDelOpt = $pdo->prepare("UPDATE sh_modifiers SET is_deleted = 1 WHERE group_id = ?"); + $stmtDelOpt->execute([$groupId]); } } - // Usuwanie opcji, kt├│re zosta┼éy skasowane z interfejsu - if (!empty($savedOptionIds)) { - $placeholdersOpt = implode(',', array_fill(0, count($savedOptionIds), '?')); - $stmtDelOpt = $pdo->prepare("UPDATE sh_modifiers SET is_deleted = 1 WHERE group_id = ? AND id NOT IN ($placeholdersOpt)"); - $stmtDelOpt->execute(array_merge([$groupId], $savedOptionIds)); - } else { - $stmtDelOpt = $pdo->prepare("UPDATE sh_modifiers SET is_deleted = 1 WHERE group_id = ?"); - $stmtDelOpt->execute([$groupId]); - } - $pdo->commit(); $response['success'] = true; $response['data'] = ['id' => $groupId]; - $response['message'] = "Zapisano grup─Ö modyfikator├│w i po┼é─ůczono z KSeF."; + $response['message'] = "Zapisano grup─Ö modyfikator├│w."; } catch (Exception $e) { $pdo->rollBack(); throw $e; @@ -444,34 +1832,106 @@ try { // 6. POBIERANIE PE┼üNEGO DRZEWA MODYFIKATOR├ôW (Z CZYTANIEM MACIERZY CEN) // ============================================================================== case 'get_modifiers_full': - $stmtG = $pdo->prepare("SELECT * FROM sh_modifier_groups WHERE tenant_id = ? AND is_deleted = 0 ORDER BY id ASC"); - $stmtG->execute([$tenant_id]); + try { + $stmtG = $pdo->prepare("SELECT * FROM sh_modifier_groups WHERE tenant_id = ? AND is_deleted = 0 ORDER BY id ASC"); + $stmtG->execute([$tenant_id]); + } catch (PDOException $e) { + $stmtG = $pdo->prepare("SELECT * FROM sh_modifier_groups WHERE tenant_id = ? ORDER BY id ASC"); + $stmtG->execute([$tenant_id]); + } $groups = $stmtG->fetchAll(PDO::FETCH_ASSOC); - $stmtO = $pdo->prepare("SELECT * FROM sh_modifiers WHERE is_deleted = 0"); - $stmtO->execute(); - $options = $stmtO->fetchAll(PDO::FETCH_ASSOC); - - $stmtP = $pdo->prepare("SELECT target_sku, channel, price FROM sh_price_tiers WHERE target_type = 'MODIFIER'"); - $stmtP->execute(); - $prices = $stmtP->fetchAll(PDO::FETCH_ASSOC); + $options = []; + try { + $stmtO = $pdo->prepare( + "SELECT m.* FROM sh_modifiers m + JOIN sh_modifier_groups mg ON m.group_id = mg.id + WHERE mg.tenant_id = ? AND m.is_deleted = 0" + ); + $stmtO->execute([$tenant_id]); + $options = $stmtO->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) {} + + $prices = []; + if ($hasPriceTiers) { + $stmtP = $pdo->prepare("SELECT target_sku, channel, price FROM sh_price_tiers WHERE target_type = 'MODIFIER' AND (tenant_id = ? OR tenant_id = 0) ORDER BY target_sku, channel, tenant_id DESC"); + $stmtP->execute([$tenant_id]); + $prices = $stmtP->fetchAll(PDO::FETCH_ASSOC); + } $pricesBySku = []; + $seenModP = []; foreach ($prices as $p) { + $mk = $p['target_sku'] . '|' . $p['channel']; + if (isset($seenModP[$mk])) continue; + $seenModP[$mk] = true; $pricesBySku[$p['target_sku']][] = ['channel' => $p['channel'], 'price' => (float)$p['price']]; } + $modifierLinksBySku = []; + if (AssetResolver::isReady($pdo) && !empty($options)) { + $skuList = []; + foreach ($options as $o) { + $ak = (string)($o['ascii_key'] ?? ''); + if ($ak !== '') { + $skuList[$ak] = true; + } + } + $skuKeys = array_keys($skuList); + if (!empty($skuKeys)) { + $placeholders = implode(',', array_fill(0, count($skuKeys), '?')); + $stmtL = $pdo->prepare( + "SELECT al.entity_ref AS mod_sku, al.role, al.asset_id, a.ascii_key AS asset_ascii, a.storage_url + FROM sh_asset_links al + INNER JOIN sh_assets a ON a.id = al.asset_id AND a.is_active = 1 AND a.deleted_at IS NULL + WHERE al.tenant_id = ? AND al.entity_type = 'modifier' + AND al.entity_ref IN ($placeholders) + AND al.role IN ('layer_top_down','modifier_hero') + AND al.is_active = 1 AND al.deleted_at IS NULL + ORDER BY al.sort_order ASC, al.id DESC" + ); + $stmtL->execute(array_merge([$tenant_id], $skuKeys)); + foreach ($stmtL->fetchAll(PDO::FETCH_ASSOC) as $lr) { + $msku = (string)$lr['mod_sku']; + $role = (string)$lr['role']; + if (!isset($modifierLinksBySku[$msku])) { + $modifierLinksBySku[$msku] = []; + } + if (isset($modifierLinksBySku[$msku][$role])) { + continue; + } + $modifierLinksBySku[$msku][$role] = [ + 'assetId' => (int)$lr['asset_id'], + 'asciiKey' => (string)$lr['asset_ascii'], + 'previewUrl' => AssetResolver::publicUrl((string)$lr['storage_url']), + ]; + } + } + } + $optionsByGroup = []; foreach ($options as $opt) { + $ascii = $opt['ascii_key'] ?? ''; + $layerSlot = $modifierLinksBySku[$ascii]['layer_top_down'] ?? null; + $heroSlot = $modifierLinksBySku[$ascii]['modifier_hero'] ?? null; $optionsByGroup[$opt['group_id']][] = [ 'id' => (int)$opt['id'], 'name' => $opt['name'], - 'asciiKey' => $opt['ascii_key'], - 'isDefault' => (bool)$opt['is_default'], - 'actionType' => $opt['action_type'], - 'linkedWarehouseSku' => $opt['linked_warehouse_sku'], - 'linkedQuantity' => (float)$opt['linked_quantity'], - 'priceTiers' => $pricesBySku[$opt['ascii_key']] ?? [] + 'asciiKey' => $ascii, + 'isDefault' => (bool)($opt['is_default'] ?? 0), + 'actionType' => $opt['action_type'] ?? 'NONE', + 'linkedWarehouseSku' => $opt['linked_warehouse_sku'] ?? null, + 'linkedQuantity' => (float)($opt['linked_quantity'] ?? 0), + 'priceTiers' => $pricesBySku[$ascii] ?? [], + 'hasVisualImpact' => $hasModifierVisualImpact + ? (bool)((int)($opt['has_visual_impact'] ?? 1)) + : true, + 'layerTopDownAssetId' => $layerSlot ? $layerSlot['assetId'] : null, + 'modifierHeroAssetId' => $heroSlot ? $heroSlot['assetId'] : null, + 'visualSlots' => [ + 'layer_top_down' => $layerSlot, + 'modifier_hero' => $heroSlot, + ], ]; } @@ -480,15 +1940,15 @@ try { $finalGroups[] = [ 'id' => (int)$g['id'], 'name' => $g['name'], - 'asciiKey' => $g['ascii_key'], - 'min' => (int)$g['min_selection'], - 'max' => (int)$g['max_selection'], - 'freeLimit' => (int)$g['free_limit'], - 'multiQty' => (bool)$g['allow_multi_qty'], - 'publicationStatus' => $g['publication_status'], - 'validFrom' => $g['valid_from'], - 'validTo' => $g['valid_to'], - 'isLockedByHq' => (bool)$g['is_locked_by_hq'], + 'asciiKey' => $g['ascii_key'] ?? '', + 'min' => (int)($g['min_selection'] ?? 0), + 'max' => (int)($g['max_selection'] ?? 10), + 'freeLimit' => (int)($g['free_limit'] ?? 0), + 'multiQty' => (bool)($g['allow_multi_qty'] ?? 0), + 'publicationStatus' => $g['publication_status'] ?? 'Draft', + 'validFrom' => $g['valid_from'] ?? null, + 'validTo' => $g['valid_to'] ?? null, + 'isLockedByHq' => (bool)($g['is_locked_by_hq'] ?? 0), 'options' => $optionsByGroup[$g['id']] ?? [] ]; } @@ -501,16 +1961,23 @@ try { // 7. S┼üOWNIK SUROWC├ôW MAGAZYNOWYCH (Dla RecipeMapper i ModifierInspector) // ============================================================================== case 'get_recipes_init': - $stmt = $pdo->prepare("SELECT sku, name, base_unit FROM sys_items WHERE tenant_id = ? ORDER BY name ASC"); - $stmt->execute([$tenant_id]); + try { + $stmt = $pdo->prepare("SELECT sku, name, base_unit, search_aliases FROM sys_items WHERE tenant_id = ? AND is_active = 1 AND is_deleted = 0 ORDER BY name ASC"); + $stmt->execute([$tenant_id]); + $hasAliases = true; + } catch (PDOException $colEx) { + $stmt = $pdo->prepare("SELECT sku, name, base_unit FROM sys_items WHERE tenant_id = ? ORDER BY name ASC"); + $stmt->execute([$tenant_id]); + $hasAliases = false; + } $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - $products = array_map(function($r) { + $products = array_map(function($r) use ($hasAliases) { return [ 'sku' => $r['sku'], 'name' => $r['name'], 'baseUnit' => $r['base_unit'], - 'aliases' => '' + 'aliases' => $hasAliases ? ($r['search_aliases'] ?? '') : '' ]; }, $rows); @@ -529,7 +1996,7 @@ try { $stmt = $pdo->prepare(" SELECT r.warehouse_sku, s.name, s.base_unit, r.quantity_base, r.waste_percent, r.is_packaging FROM sh_recipes r - JOIN sys_items s ON s.sku = r.warehouse_sku + JOIN sys_items s ON s.sku = r.warehouse_sku AND s.tenant_id = r.tenant_id WHERE r.menu_item_sku = ? AND r.tenant_id = ? ORDER BY r.id ASC "); @@ -583,9 +2050,389 @@ try { } break; + // ============================================================================== + // 10. EXPLODED VIEW ÔÇö Get layers + available SKUs for an item + // ============================================================================== + case 'get_visual_layers': + $itemSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['itemSku'] ?? ''); + if ($itemSku === '') throw new Exception('itemSku is required.'); + + // Item itself (for the base layer entry) + $stmtItem = $pdo->prepare( + "SELECT ascii_key, name FROM sh_menu_items + WHERE tenant_id = :tid AND ascii_key = :sku AND is_deleted = 0 LIMIT 1" + ); + $stmtItem->execute([':tid' => $tenant_id, ':sku' => $itemSku]); + $itemRow = $stmtItem->fetch(PDO::FETCH_ASSOC); + if (!$itemRow) throw new Exception("Item SKU not found: {$itemSku}"); + + // Modifier SKUs linked to this item via sh_item_modifiers Ôćĺ sh_modifiers + $stmtMods = $pdo->prepare( + "SELECT m.ascii_key AS sku, m.name, mg.id AS group_id, + mg.name AS group_name, mg.ascii_key AS group_ascii_key, + m.action_type + FROM sh_item_modifiers im + JOIN sh_modifier_groups mg ON mg.id = im.group_id AND mg.tenant_id = :tid + JOIN sh_modifiers m ON m.group_id = mg.id AND m.is_deleted = 0 AND m.is_active = 1 + JOIN sh_menu_items mi ON mi.id = im.item_id AND mi.tenant_id = :tid2 + WHERE mi.ascii_key = :sku + ORDER BY mg.name, m.name" + ); + $stmtMods->execute([':tid' => $tenant_id, ':tid2' => $tenant_id, ':sku' => $itemSku]); + $mods = $stmtMods->fetchAll(PDO::FETCH_ASSOC); + + // Build available SKUs list: base + modifiers + $availableSkus = [['sku' => $itemRow['ascii_key'], 'name' => $itemRow['name'], 'type' => 'base', 'group' => '']]; + foreach ($mods as $m) { + $availableSkus[] = [ + 'sku' => $m['sku'], + 'name' => $m['name'], + 'type' => 'modifier', + 'group' => $m['group_name'], + ]; + } + + // Existing visual layers + $stmtLayers = $pdo->prepare( + "SELECT layer_sku, asset_filename, z_index, is_base + FROM sh_visual_layers + WHERE tenant_id = :tid AND item_sku = :sku AND is_active = 1 + ORDER BY z_index ASC" + ); + $stmtLayers->execute([':tid' => $tenant_id, ':sku' => $itemSku]); + $layers = $stmtLayers->fetchAll(PDO::FETCH_ASSOC); + foreach ($layers as &$l) { + $l['z_index'] = (int)$l['z_index']; + $l['is_base'] = (bool)$l['is_base']; + } + unset($l); + + // ---- Upsell groups: modifier groups with prices ---- + $upsellGroups = []; + $seenGroupIds = []; + foreach ($mods as $m) { + if (!isset($seenGroupIds[$m['group_id']])) { + $seenGroupIds[$m['group_id']] = [ + 'groupId' => (int)$m['group_id'], + 'name' => $m['group_name'], + 'asciiKey' => $m['group_ascii_key'] ?? '', + 'options' => [], + ]; + } + $opt = [ + 'sku' => $m['sku'], + 'name' => $m['name'], + 'price' => null, + 'imageUrl' => null, + ]; + $seenGroupIds[$m['group_id']]['options'][] = $opt; + } + + // Fetch prices from sh_price_tiers for modifier SKUs + $modSkus = array_column($mods, 'sku'); + if (!empty($modSkus)) { + $placeholders = implode(',', array_fill(0, count($modSkus), '?')); + $stmtPrices = $pdo->prepare( + "SELECT target_sku, price, channel + FROM sh_price_tiers + WHERE target_type = 'MODIFIER' + AND target_sku IN ({$placeholders}) + AND (tenant_id = ? OR tenant_id = 0) + ORDER BY tenant_id DESC" + ); + $priceParams = array_merge($modSkus, [$tenant_id]); + $stmtPrices->execute($priceParams); + $priceRows = $stmtPrices->fetchAll(PDO::FETCH_ASSOC); + + $priceMap = []; + foreach ($priceRows as $pr) { + if (!isset($priceMap[$pr['target_sku']])) { + $priceMap[$pr['target_sku']] = (float)$pr['price']; + } + } + + foreach ($seenGroupIds as &$grp) { + foreach ($grp['options'] as &$opt) { + if (isset($priceMap[$opt['sku']])) { + $opt['price'] = $priceMap[$opt['sku']]; + } + } + unset($opt); + } + unset($grp); + } + $upsellGroups = array_values($seenGroupIds); + + // ---- Explicit companions from sh_board_companions ---- + $companions = []; + try { + $stmtComp = $pdo->prepare( + "SELECT bc.companion_sku, bc.companion_type, bc.board_slot, + bc.asset_filename, bc.display_order, + mi.name, mi.image_url + FROM sh_board_companions bc + JOIN sh_menu_items mi ON mi.ascii_key = bc.companion_sku AND mi.tenant_id = bc.tenant_id + WHERE bc.tenant_id = :tid AND bc.item_sku = :sku AND bc.is_active = 1 + ORDER BY bc.display_order" + ); + $stmtComp->execute([':tid' => $tenant_id, ':sku' => $itemSku]); + $compRows = $stmtComp->fetchAll(PDO::FETCH_ASSOC); + + // Fetch companion prices + $compSkus = array_column($compRows, 'companion_sku'); + $compPriceMap = []; + if (!empty($compSkus)) { + $ph = implode(',', array_fill(0, count($compSkus), '?')); + $stmtCP = $pdo->prepare( + "SELECT target_sku, price FROM sh_price_tiers + WHERE target_type = 'ITEM' AND target_sku IN ({$ph}) + AND (tenant_id = ? OR tenant_id = 0) + ORDER BY tenant_id DESC" + ); + $stmtCP->execute(array_merge($compSkus, [$tenant_id])); + foreach ($stmtCP->fetchAll(PDO::FETCH_ASSOC) as $cp) { + if (!isset($compPriceMap[$cp['target_sku']])) { + $compPriceMap[$cp['target_sku']] = (float)$cp['price']; + } + } + } + + foreach ($compRows as $cr) { + $companions[] = [ + 'sku' => $cr['companion_sku'], + 'name' => $cr['name'], + 'type' => $cr['companion_type'], + 'slot' => (int)$cr['board_slot'], + 'price' => $compPriceMap[$cr['companion_sku']] ?? null, + 'assetFilename' => $cr['asset_filename'], + 'imageUrl' => $cr['image_url'], + 'displayOrder' => (int)$cr['display_order'], + ]; + } + } catch (Exception $eComp) { + // sh_board_companions may not exist yet ÔÇö graceful degradation + error_log('[MenuStudio] board_companions lookup skipped: ' . $eComp->getMessage()); + } + + $response['success'] = true; + $response['message'] = 'Visual context loaded.'; + $response['data'] = [ + 'item_sku' => $itemSku, + 'available_skus' => $availableSkus, + 'layers' => $layers, + 'asset_base_url' => '../../uploads/visual/' . $tenant_id . '/', + 'upsell_groups' => $upsellGroups, + 'companions' => $companions, + ]; + break; + + // ============================================================================== + // 11. EXPLODED VIEW ÔÇö Upsert a single visual layer + // ============================================================================== + case 'save_visual_layer': + $itemSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['itemSku'] ?? ''); + $layerSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['layerSku'] ?? ''); + $assetFn = preg_replace('/[^a-zA-Z0-9_.\-]/', '', $input['assetFilename'] ?? ''); + $zIndex = max(0, min(100, (int)($input['zIndex'] ?? 0))); + $isBase = !empty($input['isBase']) ? 1 : 0; + + if ($itemSku === '' || $layerSku === '' || $assetFn === '') { + throw new Exception('itemSku, layerSku, and assetFilename are all required.'); + } + + $stmt = $pdo->prepare( + "INSERT INTO sh_visual_layers + (tenant_id, item_sku, layer_sku, asset_filename, z_index, is_base) + VALUES (:tid, :item, :layer, :asset, :z, :base) + ON DUPLICATE KEY UPDATE + asset_filename = VALUES(asset_filename), + z_index = VALUES(z_index), + is_base = VALUES(is_base), + is_active = 1, + updated_at = NOW()" + ); + $stmt->execute([ + ':tid' => $tenant_id, + ':item' => $itemSku, + ':layer' => $layerSku, + ':asset' => $assetFn, + ':z' => $zIndex, + ':base' => $isBase, + ]); + + $response['success'] = true; + $response['message'] = 'Layer saved.'; + break; + + // ============================================================================== + // 12. EXPLODED VIEW ÔÇö Delete a visual layer + // ============================================================================== + case 'delete_visual_layer': + $itemSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['itemSku'] ?? ''); + $layerSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['layerSku'] ?? ''); + if ($itemSku === '' || $layerSku === '') { + throw new Exception('itemSku and layerSku are required.'); + } + + // Fetch filename to delete from disk + $stmtGet = $pdo->prepare( + "SELECT asset_filename FROM sh_visual_layers + WHERE tenant_id = :tid AND item_sku = :item AND layer_sku = :layer LIMIT 1" + ); + $stmtGet->execute([':tid' => $tenant_id, ':item' => $itemSku, ':layer' => $layerSku]); + $row = $stmtGet->fetch(PDO::FETCH_ASSOC); + + if ($row) { + $filePath = realpath(__DIR__ . '/../../') . '/uploads/visual/' . $tenant_id . '/' . $row['asset_filename']; + if (file_exists($filePath) && is_file($filePath)) { + @unlink($filePath); + } + } + + $stmtDel = $pdo->prepare( + "DELETE FROM sh_visual_layers + WHERE tenant_id = :tid AND item_sku = :item AND layer_sku = :layer" + ); + $stmtDel->execute([':tid' => $tenant_id, ':item' => $itemSku, ':layer' => $layerSku]); + + $response['success'] = true; + $response['message'] = 'Layer deleted.'; + break; + + // ============================================================================== + // 13. BOARD COMPANIONS ÔÇö Upsert an explicit companion link + // ============================================================================== + case 'save_board_companion': + $itemSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['itemSku'] ?? ''); + $companionSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['companionSku'] ?? ''); + $compType = $input['companionType'] ?? 'extra'; + $boardSlot = max(0, min(5, (int)($input['boardSlot'] ?? 0))); + $assetFn = preg_replace('/[^a-zA-Z0-9_.\-]/', '', $input['assetFilename'] ?? ''); + $displayOrder = max(0, (int)($input['displayOrder'] ?? 0)); + + if ($itemSku === '' || $companionSku === '') { + throw new Exception('itemSku and companionSku are required.'); + } + + $allowedTypes = ['sauce', 'drink', 'side', 'dessert', 'extra']; + if (!in_array($compType, $allowedTypes, true)) { + $compType = 'extra'; + } + + $stmt = $pdo->prepare( + "INSERT INTO sh_board_companions + (tenant_id, item_sku, companion_sku, companion_type, board_slot, asset_filename, display_order) + VALUES (:tid, :item, :comp, :ctype, :slot, :asset, :dord) + ON DUPLICATE KEY UPDATE + companion_type = VALUES(companion_type), + board_slot = VALUES(board_slot), + asset_filename = VALUES(asset_filename), + display_order = VALUES(display_order), + is_active = 1, + updated_at = NOW()" + ); + $stmt->execute([ + ':tid' => $tenant_id, + ':item' => $itemSku, + ':comp' => $companionSku, + ':ctype' => $compType, + ':slot' => $boardSlot, + ':asset' => $assetFn ?: null, + ':dord' => $displayOrder, + ]); + + $response['success'] = true; + $response['message'] = 'Companion saved.'; + break; + + // ============================================================================== + // 14. BOARD COMPANIONS ÔÇö Delete a companion link + // ============================================================================== + case 'delete_board_companion': + $itemSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['itemSku'] ?? ''); + $companionSku = preg_replace('/[^a-zA-Z0-9_\-]/', '', $input['companionSku'] ?? ''); + + if ($itemSku === '' || $companionSku === '') { + throw new Exception('itemSku and companionSku are required.'); + } + + $stmtDel = $pdo->prepare( + "DELETE FROM sh_board_companions + WHERE tenant_id = :tid AND item_sku = :item AND companion_sku = :comp" + ); + $stmtDel->execute([':tid' => $tenant_id, ':item' => $itemSku, ':comp' => $companionSku]); + + $response['success'] = true; + $response['message'] = 'Companion deleted.'; + break; + + // ============================================================================== + // [REMOVED ┬Ě M025] 15-17 Ingredient Library (get/save/delete_ingredient_asset) + // + 18 get_board_context + // + // Ca┼éy blok bazowa┼é na `sh_ingredient_assets` (zast─ůpione przez Asset Studio + // i Unified Asset Library sh_assets + sh_asset_links w m021). ┼╗adne wywo┼éania + // z frontend├│w (studio/menu_studio, online, online_studio) ich ju┼╝ nie u┼╝ywa┼éy, + // dlatego zosta┼éy usuni─Öte razem z tabel─ů `sh_ingredient_assets` (m025). + // + // Board context dla online/storefront jest teraz sk┼éadany przez + // ÔÇó api/online/engine.php#get_dish (legacy endpoint, wci─ů┼╝ u┼╝ywany) + // ÔÇó api/online/engine.php#get_scene_dish (nowy kontrakt, Scene Studio) + // kt├│re wczytuj─ů warstwy z sh_visual_layers + sh_asset_links (layer_top_down). + // ============================================================================== + case 'get_board_context': + // Kr├│tki defensywny error ÔÇö gdyby co┼Ť legacy jeszcze wo┼éa┼éo: + throw new Exception('get_board_context usuni─Öte w m025 ÔÇö u┼╝yj online/get_scene_dish lub online/get_dish.'); + + case 'get_ingredient_assets': + case 'save_ingredient_asset': + case 'delete_ingredient_asset': + throw new Exception('Ingredient Library (sh_ingredient_assets) usuni─Öte w m025 ÔÇö u┼╝yj Asset Studio (sh_assets + sh_asset_links).'); + // ============================================================================== // TARCZA DIAGNOSTYCZNA (Wy┼éapuje zmy┼Ťlone akcje JS) // ============================================================================== + // ============================================================================== + // GLOBAL ASSETS ÔÇö Fetch photorealistic .webp assets from sh_global_assets + // ============================================================================== + case 'get_global_assets': + $category = preg_replace('/[^a-z]/', '', $input['category'] ?? ''); + + $sql = "SELECT id, ascii_key, category, sub_type, filename, width, height, + has_alpha, filesize_bytes, z_order, target_px + FROM sh_global_assets + WHERE (tenant_id = 0 OR tenant_id = :tid) AND is_active = 1"; + $params = [':tid' => $tenant_id]; + + if ($category !== '') { + $sql .= " AND category = :cat"; + $params[':cat'] = $category; + } + + $sql .= " ORDER BY z_order ASC, ascii_key ASC"; + + $stmtGA = $pdo->prepare($sql); + $stmtGA->execute($params); + $globalAssets = $stmtGA->fetchAll(PDO::FETCH_ASSOC); + + foreach ($globalAssets as &$ga) { + $ga['id'] = (int)$ga['id']; + $ga['width'] = (int)$ga['width']; + $ga['height'] = (int)$ga['height']; + $ga['hasAlpha'] = (bool)$ga['has_alpha']; + $ga['zOrder'] = (int)$ga['z_order']; + $ga['targetPx'] = (int)$ga['target_px']; + $ga['filesize'] = (int)$ga['filesize_bytes']; + $ga['url'] = '/slicehub/uploads/global_assets/' . $ga['filename']; + unset($ga['has_alpha'], $ga['z_order'], $ga['target_px'], $ga['filesize_bytes']); + } + unset($ga); + + $response['success'] = true; + $response['data'] = ['assets' => $globalAssets]; + $response['message'] = count($globalAssets) . ' assets loaded.'; + break; + default: $unknown = $action ?: 'PUSTA_AKCJA'; throw new Exception("Nieznana akcja API: [{$unknown}] - Prawdopodobnie stara wersja JS!"); @@ -593,7 +2440,12 @@ try { } catch (Exception $e) { $response['success'] = false; $response['data'] = null; - $response['message'] = $e->getMessage(); + $msg = $e->getMessage(); + $isBizLogic = !($e instanceof PDOException) + && !str_contains($msg, 'SQLSTATE') + && !str_contains($msg, 'Base table'); + $response['message'] = $isBizLogic ? $msg : 'Internal server error.'; + error_log('[MenuStudio] ' . $msg . ' in ' . $e->getFile() . ':' . $e->getLine()); } echo json_encode($response); diff --git a/api/cart/CartEngine.php b/api/cart/CartEngine.php index a2c0566..2596e44 100644 --- a/api/cart/CartEngine.php +++ b/api/cart/CartEngine.php @@ -14,6 +14,327 @@ class CartEngineException extends RuntimeException {} class CartEngine { + // ========================================================================= + // AUTO-PROMOTIONS (sh_promotions) ÔÇö Faza 4.1 + // Helpery prywatne do oceny rule_json wzgl─Ödem stanu koszyka. + // ========================================================================= + + /** + * Suma line_total_grosze dla linii pasuj─ůcych do SKU. + * @param list $lines + */ + private static function sumLinesBySku(array $lines, string $sku): int + { + if ($sku === '') return 0; + $sum = 0; + foreach ($lines as $ln) { + if (($ln['item_sku'] ?? '') === $sku) { + $sum += (int)($ln['line_total_grosze'] ?? 0); + } + } + return $sum; + } + + /** + * Suma line_total_grosze dla linii z kategorii (po mapie sku Ôćĺ category_id). + * @param array $catMap + */ + private static function sumLinesByCategory(array $lines, int $categoryId, array $catMap): int + { + if ($categoryId <= 0) return 0; + $sum = 0; + foreach ($lines as $ln) { + $sku = (string)($ln['item_sku'] ?? ''); + if (($catMap[$sku] ?? 0) === $categoryId) { + $sum += (int)($ln['line_total_grosze'] ?? 0); + } + } + return $sum; + } + + /** + * Najta┼äsza jednostka (unit_price_grosze) dla SKU w koszyku albo null gdy brak. + */ + private static function cheapestUnitBySku(array $lines, string $sku): ?int + { + if ($sku === '') return null; + $min = null; + foreach ($lines as $ln) { + if (($ln['item_sku'] ?? '') === $sku) { + $unit = (int)($ln['unit_price_grosze'] ?? 0); + if ($unit > 0 && ($min === null || $unit < $min)) $min = $unit; + } + } + return $min; + } + + /** + * Dodatkowe gating po godzinie / dniu tygodnia. + * time_window_json: { days:[1..7] (ISO, 1=Pn), start:"HH:MM", end:"HH:MM" } + */ + private static function isInTimeWindow(?string $twJson, DateTimeImmutable $now): bool + { + if ($twJson === null || $twJson === '') return true; + $tw = json_decode($twJson, true); + if (!is_array($tw)) return true; + + if (!empty($tw['days']) && is_array($tw['days'])) { + $dow = (int)$now->format('N'); + $days = array_map('intval', $tw['days']); + if (!in_array($dow, $days, true)) return false; + } + if (!empty($tw['start']) && !empty($tw['end'])) { + $timeNow = $now->format('H:i'); + $s = (string)$tw['start']; + $e = (string)$tw['end']; + if ($s <= $e) { + if ($timeNow < $s || $timeNow > $e) return false; + } else { + // okno przez p├│┼énoc + if ($timeNow < $s && $timeNow > $e) return false; + } + } + return true; + } + + /** + * Wylicza rabat w grosze dla jednej promocji. + * @param array{id:int,ascii_key:string,name:string,rule_kind:string,rule:?array,badge_text:?string,badge_style:?string} $promo + * @param array{subtotal_grosze:int,lines:array,category_map:array} $ctx + * @return array{promotion_id:int,ascii_key:string,name:string,rule_kind:string,badge_text:?string,badge_style:string,discount_grosze:int,note:string}|null + */ + private static function evaluateRule(array $promo, array $ctx): ?array + { + $kind = (string)$promo['rule_kind']; + $rule = $promo['rule'] ?? null; + if (!is_array($rule)) return null; + + $discount = 0; + $note = ''; + $subtotal = (int)$ctx['subtotal_grosze']; + $lines = $ctx['lines']; + $catMap = $ctx['category_map']; + + switch ($kind) { + case 'discount_percent': { + $target = (string)($rule['target'] ?? 'cart'); + $percent = (float)($rule['percent'] ?? 0); + if ($percent <= 0 || $percent > 100) return null; + $minSubtotal = isset($rule['min_subtotal']) + ? (int)round((float)$rule['min_subtotal'] * 100) : 0; + if ($subtotal < $minSubtotal) return null; + + if ($target === 'cart') { + $discount = (int)round($subtotal * $percent / 100); + $note = "-{$percent}% od koszyka"; + } elseif ($target === 'item') { + $sku = (string)($rule['sku'] ?? ''); + $matched = self::sumLinesBySku($lines, $sku); + $discount = (int)round($matched * $percent / 100); + $note = "-{$percent}% na {$sku}"; + } elseif ($target === 'category') { + $cid = (int)($rule['category_id'] ?? 0); + $matched = self::sumLinesByCategory($lines, $cid, $catMap); + $discount = (int)round($matched * $percent / 100); + $note = "-{$percent}% na kategori─Ö #{$cid}"; + } + break; + } + + case 'discount_amount': { + $target = (string)($rule['target'] ?? 'cart'); + $amountGrosze = (int)round((float)($rule['amount'] ?? 0) * 100); + if ($amountGrosze <= 0) return null; + $minSubtotal = isset($rule['min_subtotal']) + ? (int)round((float)$rule['min_subtotal'] * 100) : 0; + if ($subtotal < $minSubtotal) return null; + + if ($target === 'cart') { + $discount = min($amountGrosze, $subtotal); + $note = '-' . number_format($amountGrosze / 100, 2) . ' od koszyka'; + } elseif ($target === 'item') { + $sku = (string)($rule['sku'] ?? ''); + $matched = self::sumLinesBySku($lines, $sku); + if ($matched <= 0) return null; + $discount = min($amountGrosze, $matched); + $note = '-' . number_format($discount / 100, 2) . " na {$sku}"; + } elseif ($target === 'category') { + $cid = (int)($rule['category_id'] ?? 0); + $matched = self::sumLinesByCategory($lines, $cid, $catMap); + if ($matched <= 0) return null; + $discount = min($amountGrosze, $matched); + $note = '-' . number_format($discount / 100, 2) . " na kategori─Ö #{$cid}"; + } + break; + } + + case 'combo_half_price': { + // Kup `anchor_sku`, drug─ů `combo_sku` za `percent`% ceny (default 50%). + $anchorSku = (string)($rule['anchor_sku'] ?? ''); + $comboSku = (string)($rule['combo_sku'] ?? ''); + $percent = (float)($rule['percent'] ?? 50); + if ($anchorSku === '' || $comboSku === '' || $percent <= 0 || $percent > 100) return null; + + if (self::sumLinesBySku($lines, $anchorSku) <= 0) return null; + $comboUnit = self::cheapestUnitBySku($lines, $comboSku); + if ($comboUnit === null) return null; + + $discount = (int)round($comboUnit * $percent / 100); + $note = "kombo {$anchorSku} + -{$percent}% na {$comboSku}"; + break; + } + + case 'free_item_if_threshold': { + $minSubtotal = (int)round((float)($rule['min_subtotal'] ?? 0) * 100); + $freeSku = (string)($rule['free_sku'] ?? ''); + if ($subtotal < $minSubtotal || $freeSku === '') return null; + + $unit = self::cheapestUnitBySku($lines, $freeSku); + if ($unit === null) return null; + $discount = $unit; + $note = "gratis {$freeSku} (pr├│g " . number_format($minSubtotal / 100, 2) . ')'; + break; + } + + case 'bundle': { + $skus = $rule['skus'] ?? []; + $bundlePriceGrosze = (int)round((float)($rule['bundle_price'] ?? 0) * 100); + if (!is_array($skus) || count($skus) < 2 || $bundlePriceGrosze <= 0) return null; + + $bundleValue = 0; + foreach ($skus as $sku) { + $unit = self::cheapestUnitBySku($lines, (string)$sku); + if ($unit === null) return null; + $bundleValue += $unit; + } + if ($bundleValue <= $bundlePriceGrosze) return null; + $discount = $bundleValue - $bundlePriceGrosze; + $note = 'bundle (-' . number_format($discount / 100, 2) . ')'; + break; + } + + default: + return null; + } + + if ($discount <= 0) return null; + $discount = min($discount, $subtotal); + + return [ + 'promotion_id' => (int)$promo['id'], + 'ascii_key' => (string)$promo['ascii_key'], + 'name' => (string)$promo['name'], + 'rule_kind' => $kind, + 'badge_text' => $promo['badge_text'] ?? null, + 'badge_style' => (string)($promo['badge_style'] ?? 'amber'), + 'discount_grosze' => $discount, + 'note' => $note, + ]; + } + + /** + * ┼üaduje aktywne sh_promotions dla tenanta (time-gated po SQL) i ocenia je. + * MVP: best-wins ÔÇö wybiera jedn─ů promocj─Ö daj─ůc─ů najwi─Ökszy rabat. + * V2 (future): flaga `stackable:true` w rule_json, priorities, wykluczenia. + * + * @return array{discount_grosze:int, applied:list} + */ + private static function applyAutoPromotions( + PDO $pdo, + int $tenantId, + int $subtotalGrosze, + array $linesRaw + ): array { + // Schema detection ÔÇö gdy sh_promotions nie istnieje (migracja 022 nie przesz┼éa), zwracamy zera. + try { + $pdo->query('SELECT 1 FROM sh_promotions LIMIT 0'); + } catch (\PDOException $e) { + return ['discount_grosze' => 0, 'applied' => []]; + } + + // Load + try { + $stmt = $pdo->prepare( + "SELECT id, ascii_key, name, rule_kind, rule_json, + badge_text, badge_style, time_window_json + FROM sh_promotions + WHERE tenant_id = :tid AND is_active = 1 + AND (valid_from IS NULL OR valid_from <= NOW()) + AND (valid_to IS NULL OR valid_to >= NOW()) + ORDER BY id ASC" + ); + $stmt->execute([':tid' => $tenantId]); + $promoRows = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (\PDOException $e) { + return ['discount_grosze' => 0, 'applied' => []]; + } + if (!$promoRows) return ['discount_grosze' => 0, 'applied' => []]; + + // Batch mapa sku Ôćĺ category_id (dla target=category) + $catMap = []; + $skus = array_values(array_unique(array_filter(array_map( + fn($l) => (string)($l['item_sku'] ?? ''), + $linesRaw + )))); + if ($skus) { + try { + $ph = implode(',', array_fill(0, count($skus), '?')); + $stmtCat = $pdo->prepare( + "SELECT ascii_key, category_id FROM sh_menu_items + WHERE tenant_id = ? AND ascii_key IN ({$ph})" + ); + $stmtCat->execute(array_merge([$tenantId], $skus)); + foreach ($stmtCat->fetchAll(PDO::FETCH_ASSOC) as $r) { + $catMap[(string)$r['ascii_key']] = (int)($r['category_id'] ?? 0); + } + } catch (\PDOException $e) { + // skip ÔÇö target=category nie zadzia┼éa, ale reszta tak + } + } + + $now = new DateTimeImmutable('now'); + $ctx = [ + 'subtotal_grosze' => $subtotalGrosze, + 'lines' => $linesRaw, + 'category_map' => $catMap, + ]; + + $candidates = []; + foreach ($promoRows as $row) { + if (!self::isInTimeWindow($row['time_window_json'] ?? null, $now)) continue; + + $rule = null; + if (!empty($row['rule_json'])) { + $decoded = json_decode((string)$row['rule_json'], true); + if (is_array($decoded)) $rule = $decoded; + } + + $evaluated = self::evaluateRule([ + 'id' => (int)$row['id'], + 'ascii_key' => (string)$row['ascii_key'], + 'name' => (string)$row['name'], + 'rule_kind' => (string)$row['rule_kind'], + 'rule' => $rule, + 'badge_text' => $row['badge_text'] ?? null, + 'badge_style' => $row['badge_style'] ?? 'amber', + ], $ctx); + + if ($evaluated !== null) $candidates[] = $evaluated; + } + + if (!$candidates) return ['discount_grosze' => 0, 'applied' => []]; + + // Best-wins (MVP) + usort($candidates, fn($a, $b) => $b['discount_grosze'] <=> $a['discount_grosze']); + $best = $candidates[0]; + + return [ + 'discount_grosze' => $best['discount_grosze'], + 'applied' => [$best], + ]; + } + + /** * Run a full server-authoritative cart calculation. * @@ -87,7 +408,8 @@ class CartEngine $stmtItemPrice = $pdo->prepare( "SELECT price FROM sh_price_tiers WHERE target_type = 'ITEM' AND target_sku = :sku AND channel = :channel - LIMIT 1" + AND (tenant_id = :tid OR tenant_id = 0) + ORDER BY tenant_id DESC LIMIT 1" ); $stmtModifier = $pdo->prepare( @@ -104,13 +426,15 @@ class CartEngine $stmtModPrice = $pdo->prepare( "SELECT price FROM sh_price_tiers WHERE target_type = 'MODIFIER' AND target_sku = :sku AND channel = :channel - LIMIT 1" + AND (tenant_id = :tid OR tenant_id = 0) + ORDER BY tenant_id DESC LIMIT 1" ); $stmtModPriceFallback = $pdo->prepare( "SELECT price FROM sh_price_tiers WHERE target_type = 'MODIFIER' AND target_sku = :sku AND channel = 'POS' - LIMIT 1" + AND (tenant_id = :tid OR tenant_id = 0) + ORDER BY tenant_id DESC LIMIT 1" ); $stmtWarehouseItem = $pdo->prepare( @@ -125,15 +449,15 @@ class CartEngine $fmtMoney = fn(int $grosze): string => number_format($grosze / 100, 2, '.', ''); $fmtRate = fn(float $rate): string => number_format($rate, 2, '.', ''); - $resolveModPrice = function (string $modSku) use ($stmtModPrice, $stmtModPriceFallback, $channel): int { - $stmtModPrice->execute([':sku' => $modSku, ':channel' => $channel]); + $resolveModPrice = function (string $modSku) use ($stmtModPrice, $stmtModPriceFallback, $channel, $tenantId): int { + $stmtModPrice->execute([':sku' => $modSku, ':channel' => $channel, ':tid' => $tenantId]); $row = $stmtModPrice->fetch(PDO::FETCH_ASSOC); if ($row) { return (int)round((float)$row['price'] * 100); } if ($channel !== 'POS') { - $stmtModPriceFallback->execute([':sku' => $modSku]); + $stmtModPriceFallback->execute([':sku' => $modSku, ':tid' => $tenantId]); $row = $stmtModPriceFallback->fetch(PDO::FETCH_ASSOC); if ($row) { return (int)round((float)$row['price'] * 100); @@ -187,7 +511,7 @@ class CartEngine throw new CartEngineException("Line #{$idx}: half_a_sku '{$halfASku}' not found for this tenant."); } - $stmtItemPrice->execute([':sku' => $halfASku, ':channel' => $channel]); + $stmtItemPrice->execute([':sku' => $halfASku, ':channel' => $channel, ':tid' => $tenantId]); $pA = $stmtItemPrice->fetch(PDO::FETCH_ASSOC); if (!$pA) { throw new CartEngineException("Line #{$idx}: no '{$channel}' price tier for half_a_sku '{$halfASku}'."); @@ -200,7 +524,7 @@ class CartEngine throw new CartEngineException("Line #{$idx}: half_b_sku '{$halfBSku}' not found for this tenant."); } - $stmtItemPrice->execute([':sku' => $halfBSku, ':channel' => $channel]); + $stmtItemPrice->execute([':sku' => $halfBSku, ':channel' => $channel, ':tid' => $tenantId]); $pB = $stmtItemPrice->fetch(PDO::FETCH_ASSOC); if (!$pB) { throw new CartEngineException("Line #{$idx}: no '{$channel}' price tier for half_b_sku '{$halfBSku}'."); @@ -240,7 +564,7 @@ class CartEngine throw new CartEngineException("Line #{$idx}: SKU '{$effectiveSku}' not found for this tenant."); } - $stmtItemPrice->execute([':sku' => $effectiveSku, ':channel' => $channel]); + $stmtItemPrice->execute([':sku' => $effectiveSku, ':channel' => $channel, ':tid' => $tenantId]); $priceRow = $stmtItemPrice->fetch(PDO::FETCH_ASSOC); if (!$priceRow) { throw new CartEngineException("Line #{$idx}: no '{$channel}' price tier for SKU '{$effectiveSku}'."); @@ -359,24 +683,46 @@ class CartEngine $resultLines[] = $lineOutput; $linesRaw[] = [ - 'item_sku' => $effectiveSku, - 'snapshot_name' => $snapshotName, - 'unit_price_grosze'=> $unitPriceGrosze, - 'quantity' => $quantity, - 'line_total_grosze'=> $lineTotalGrosze, - 'vat_rate' => $vatRate, - 'vat_amount_grosze'=> $vatAmountGrosze, + 'line_id' => $lineId, + 'item_sku' => $effectiveSku, + 'snapshot_name' => $snapshotName, + 'unit_price_grosze' => $unitPriceGrosze, + 'quantity' => $quantity, + 'line_total_grosze' => $lineTotalGrosze, + 'vat_rate' => $vatRate, + 'vat_amount_grosze' => $vatAmountGrosze, + 'modifiers_json' => !empty($resolvedModifiers) + ? json_encode($resolvedModifiers, JSON_UNESCAPED_UNICODE) + : null, + 'removed_ingredients_json' => !empty($resolvedRemovals) + ? json_encode($resolvedRemovals, JSON_UNESCAPED_UNICODE) + : null, + 'comment' => $comment !== '' ? $comment : null, ]; } // ===================================================================== - // 6. PROMO / DISCOUNT ENGINE + // 5.5. AUTO-PROMOTIONS (sh_promotions ÔÇö M022) ÔÇö Faza 4.1 + // + // Promocje auto-aplikowane (bez kodu) ÔÇö time-gated w SQL, window-gated + // w PHP. MVP: best-wins (jedna promocja = najwi─Ökszy rabat). Aplikujemy + // PRZED promo_code aby kod liczy┼é si─Ö od subtotal after auto-promo. + // ===================================================================== + $autoPromoResult = self::applyAutoPromotions($pdo, $tenantId, $subtotalGrosze, $linesRaw); + $autoDiscountGrosze = (int)$autoPromoResult['discount_grosze']; + $appliedAutoPromos = $autoPromoResult['applied']; + + // ===================================================================== + // 6. PROMO / DISCOUNT ENGINE (kod r─Öczny ÔÇö sh_promo_codes, legacy) // ===================================================================== $discountGrosze = 0; $appliedDiscount = null; $appliedPromoCode = null; $promoCode = trim($input['promo_code'] ?? ''); + // Subtotal ÔÇ×po" auto-promocji dla oceny progu kodu (min_order_value) + $subtotalForCode = max(0, $subtotalGrosze - $autoDiscountGrosze); + if ($promoCode !== '') { $stmtPromo = $pdo->prepare( "SELECT code, type, value, min_order_value, max_uses, current_uses, @@ -401,7 +747,7 @@ class CartEngine $isValid = $now >= $promo['valid_from'] && $now <= $promo['valid_to'] && (int)$promo['current_uses'] < (int)$promo['max_uses'] - && $subtotalGrosze >= $minOrderGrosze + && $subtotalForCode >= $minOrderGrosze && in_array($channel, $allowedList, true); if ($isValid) { @@ -409,32 +755,36 @@ class CartEngine $value = (float)$promo['value']; if ($type === 'percentage') { - $discountGrosze = (int)round(($subtotalGrosze * $value) / 100); + $discountGrosze = (int)round(($subtotalForCode * $value) / 100); } elseif ($type === 'fixed_amount') { $discountGrosze = (int)($value * 100); } - $discountGrosze = min($discountGrosze, $subtotalGrosze); + $discountGrosze = min($discountGrosze, $subtotalForCode); if ($discountGrosze > 0) { $appliedPromoCode = $promo['code']; $appliedDiscount = [ 'code' => $promo['code'], 'type' => $type, - 'subtotal_before' => $fmtMoney($subtotalGrosze), + 'subtotal_before' => $fmtMoney($subtotalForCode), 'discount_amount' => $fmtMoney($discountGrosze), - 'subtotal_after' => $fmtMoney($subtotalGrosze - $discountGrosze), + 'subtotal_after' => $fmtMoney($subtotalForCode - $discountGrosze), ]; } } } } + // ┼ü─ůczny rabat = auto-promocje + kod r─Öczny + $totalDiscountGrosze = $autoDiscountGrosze + $discountGrosze; + $totalDiscountGrosze = min($totalDiscountGrosze, $subtotalGrosze); + // ===================================================================== // 7. ORDER TOTALS // ===================================================================== $deliveryFeeGrosze = 0; - $grandTotalGrosze = $subtotalGrosze - $discountGrosze + $deliveryFeeGrosze; + $grandTotalGrosze = $subtotalGrosze - $totalDiscountGrosze + $deliveryFeeGrosze; $loyaltyPoints = (int)floor($grandTotalGrosze / 10); $vatSummary = []; @@ -456,7 +806,7 @@ class CartEngine 'order_type' => $orderType, 'lines' => $resultLines, 'subtotal' => $fmtMoney($subtotalGrosze), - 'discount' => $fmtMoney($discountGrosze), + 'discount' => $fmtMoney($totalDiscountGrosze), 'delivery_fee' => $fmtMoney($deliveryFeeGrosze), 'grand_total' => $fmtMoney($grandTotalGrosze), 'vat_summary' => $vatSummary, @@ -466,17 +816,35 @@ class CartEngine $responseData['applied_discount'] = $appliedDiscount; } + // Auto-promocje ÔÇö format czytelny dla klienta (grosze Ôćĺ string "z┼é.gr") + if (!empty($appliedAutoPromos)) { + $responseData['applied_auto_promotions'] = array_map(fn($p) => [ + 'promotionId' => $p['promotion_id'], + 'asciiKey' => $p['ascii_key'], + 'name' => $p['name'], + 'ruleKind' => $p['rule_kind'], + 'badgeText' => $p['badge_text'], + 'badgeStyle' => $p['badge_style'], + 'discount' => $fmtMoney($p['discount_grosze']), + 'note' => $p['note'], + ], $appliedAutoPromos); + $responseData['auto_promotion_discount'] = $fmtMoney($autoDiscountGrosze); + } + return [ - 'channel' => $channel, - 'order_type' => $orderType, - 'subtotal_grosze' => $subtotalGrosze, - 'discount_grosze' => $discountGrosze, - 'delivery_fee_grosze'=> $deliveryFeeGrosze, - 'grand_total_grosze' => $grandTotalGrosze, - 'loyalty_points' => $loyaltyPoints, - 'applied_promo_code' => $appliedPromoCode, - 'lines_raw' => $linesRaw, - 'response' => $responseData, + 'channel' => $channel, + 'order_type' => $orderType, + 'subtotal_grosze' => $subtotalGrosze, + 'discount_grosze' => $totalDiscountGrosze, + 'auto_discount_grosze' => $autoDiscountGrosze, + 'code_discount_grosze' => $discountGrosze, + 'delivery_fee_grosze' => $deliveryFeeGrosze, + 'grand_total_grosze' => $grandTotalGrosze, + 'loyalty_points' => $loyaltyPoints, + 'applied_promo_code' => $appliedPromoCode, + 'applied_auto_promotions' => $appliedAutoPromos, + 'lines_raw' => $linesRaw, + 'response' => $responseData, ]; } } diff --git a/api/orders/checkout.php b/api/orders/checkout.php index 5437103..01210f8 100644 --- a/api/orders/checkout.php +++ b/api/orders/checkout.php @@ -1,22 +1,5 @@ $ok, 'data' => $data, 'message' => $message], JSON_UNESCAPED_UNICODE); + exit; +}; + try { require_once __DIR__ . '/../../core/db_config.php'; require_once __DIR__ . '/../../core/auth_guard.php'; require_once __DIR__ . '/../cart/CartEngine.php'; + require_once __DIR__ . '/../../core/WzEngine.php'; if (!isset($pdo)) { throw new RuntimeException('Database connection unavailable.'); } - // ========================================================================= - // 1. PARSE INPUT - // ========================================================================= - $raw = file_get_contents('php://input'); - $input = json_decode($raw, true); - + $raw = file_get_contents('php://input'); + $input = json_decode($raw ?: '{}', true); if (!is_array($input)) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => 'Invalid JSON payload.']); - exit; + $respond(false, null, 'Invalid JSON payload.', 400); } - $source = trim($input['source'] ?? 'POS'); + $source = strtoupper(trim((string)($input['source'] ?? 'POS'))); + $customerName = isset($input['customer_name']) ? trim((string)$input['customer_name']) : null; + $customerPhone = isset($input['customer_phone']) ? trim((string)$input['customer_phone']) : null; + $deliveryAddress = isset($input['delivery_address']) ? trim((string)$input['delivery_address']) : null; + $promisedTime = isset($input['requested_time']) ? trim((string)$input['requested_time']) : null; + $warehouseId = trim((string)($input['warehouse_id'] ?? 'MAIN')) ?: 'MAIN'; + $lockToken = trim((string)($input['lock_token'] ?? '')); + + $hasCheckoutLocks = false; + $hasOrdersTracking = false; + try { $pdo->query('SELECT 1 FROM sh_checkout_locks LIMIT 0'); $hasCheckoutLocks = true; } catch (Throwable $e) {} + try { $pdo->query('SELECT tracking_token FROM sh_orders LIMIT 0'); $hasOrdersTracking = true; } catch (Throwable $e) {} - // ========================================================================= - // 2. SECURE RECALCULATION (single source of truth) - // ========================================================================= $calc = CartEngine::calculate($pdo, $tenant_id, $input); - // ========================================================================= - // 3. HELPERS - // ========================================================================= - $generateUuidV4 = function (): string { - $data = random_bytes(16); + $generateUuidV4 = static function (): string { + $data = random_bytes(16); $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); }; + $fmtMoney = static fn(int $g): string => number_format($g / 100, 2, '.', ''); $prefixMap = [ 'POS' => 'ORD', - 'Takeaway' => 'ORD', - 'Delivery' => 'ORD', + 'TAKEAWAY' => 'ORD', + 'DELIVERY' => 'ORD', 'WWW' => 'WWW', + 'ONLINE' => 'WWW', 'KIOSK' => 'KIO', 'AGGREGATOR' => 'AGG', ]; $prefix = $prefixMap[$source] ?? 'ORD'; - // ========================================================================= - // 4. ATOMIC TRANSACTION - // ========================================================================= - $pdo->beginTransaction(); + if ($source === 'ONLINE') { + if (!$hasCheckoutLocks) { + $respond(false, null, 'Checkout idempotency unavailable (missing migration 017).', 400); + } + if (!$hasOrdersTracking) { + $respond(false, null, 'Guest tracking unavailable (tracking_token missing).', 400); + } + if ($lockToken === '' || !preg_match('/^[a-f0-9\-]{36}$/i', $lockToken)) { + $respond(false, null, 'Invalid lock_token.', 400); + } + + $stmtLock = $pdo->prepare( + "SELECT cart_hash, grand_total_grosze, expires_at, consumed_at + FROM sh_checkout_locks + WHERE lock_token = :tok AND tenant_id = :tid + LIMIT 1" + ); + $stmtLock->execute([':tok' => $lockToken, ':tid' => $tenant_id]); + $lock = $stmtLock->fetch(PDO::FETCH_ASSOC); + if (!$lock) { + $respond(false, null, 'lock_token not found or expired.', 400); + } + if (!empty($lock['consumed_at'])) { + $respond(false, null, 'This checkout token has already been consumed.', 409); + } + if (strtotime((string)$lock['expires_at']) < time()) { + $respond(false, null, 'Checkout token expired.', 409); + } + + $canonical = json_encode([ + 'channel' => $input['channel'] ?? null, + 'order_type' => $input['order_type'] ?? null, + 'lines' => $input['lines'] ?? [], + 'promo_code' => $input['promo_code'] ?? '', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $cartHash = hash('sha256', (string)$canonical); + if ($cartHash !== (string)$lock['cart_hash']) { + $respond(false, null, 'Cart changed after init_checkout.', 409); + } + if ((int)$lock['grand_total_grosze'] !== (int)$calc['grand_total_grosze']) { + $respond(false, null, 'Cart total changed after init_checkout.', 409); + } + } + $availability = WzEngine::checkAvailability($pdo, $tenant_id, $warehouseId, $calc['lines_raw'] ?? []); + if (($availability['success'] ?? false) && ($availability['available'] ?? true) === false) { + $respond(false, [ + 'warehouse_id' => $availability['warehouse_id'] ?? $warehouseId, + 'shortages' => $availability['shortages'] ?? [], + ], 'Insufficient warehouse stock for checkout.', 409); + } + + $trackingToken = ($source === 'ONLINE' && $hasOrdersTracking) ? bin2hex(random_bytes(8)) : null; + + $pdo->beginTransaction(); try { - // ÔÇö 4a. Atomic sequence number ÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇö $stmtSeq = $pdo->prepare( "INSERT INTO sh_order_sequences (tenant_id, date, seq) VALUES (:tid, CURDATE(), 1) @@ -98,10 +138,9 @@ try { $seq = (int)$pdo->lastInsertId(); $orderNumber = sprintf('%s/%s/%04d', $prefix, date('Ymd'), $seq); - $orderId = $generateUuidV4(); - $now = date('Y-m-d H:i:s'); + $orderId = $generateUuidV4(); + $now = date('Y-m-d H:i:s'); - // ÔÇö 4b. Promo code state mutation ÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇö if ($calc['applied_promo_code'] !== null) { $stmtPromoInc = $pdo->prepare( "UPDATE sh_promo_codes @@ -114,41 +153,50 @@ try { ]); } - // ÔÇö 4c. Insert order header ÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇö $stmtOrder = $pdo->prepare( "INSERT INTO sh_orders (id, tenant_id, order_number, channel, order_type, source, subtotal, discount_amount, delivery_fee, grand_total, - status, loyalty_points_earned, created_at) + status, payment_status, loyalty_points_earned, + customer_name, customer_phone, tracking_token, delivery_address, + promised_time, user_id, created_at) VALUES (:id, :tid, :num, :channel, :order_type, :source, :subtotal, :discount, :delivery, :grand, - 'new', :points, :now)" + 'new', 'to_pay', :points, + :cust_name, :cust_phone, :tracking_token, :del_addr, + :promised, :uid, :now)" ); $stmtOrder->execute([ - ':id' => $orderId, - ':tid' => $tenant_id, - ':num' => $orderNumber, - ':channel' => $calc['channel'], - ':order_type' => $calc['order_type'], - ':source' => $source, - ':subtotal' => $calc['subtotal_grosze'], - ':discount' => $calc['discount_grosze'], - ':delivery' => $calc['delivery_fee_grosze'], - ':grand' => $calc['grand_total_grosze'], - ':points' => $calc['loyalty_points'], - ':now' => $now, + ':id' => $orderId, + ':tid' => $tenant_id, + ':num' => $orderNumber, + ':channel' => $calc['channel'], + ':order_type' => $calc['order_type'], + ':source' => $source, + ':subtotal' => $calc['subtotal_grosze'], + ':discount' => $calc['discount_grosze'], + ':delivery' => $calc['delivery_fee_grosze'], + ':grand' => $calc['grand_total_grosze'], + ':points' => $calc['loyalty_points'], + ':cust_name' => $customerName, + ':cust_phone' => $customerPhone, + ':tracking_token' => $trackingToken, + ':del_addr' => $deliveryAddress, + ':promised' => $promisedTime !== '' ? $promisedTime : null, + ':uid' => $user_id, + ':now' => $now, ]); - // ÔÇö 4d. Insert order lines ÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇö $stmtLine = $pdo->prepare( "INSERT INTO sh_order_lines (id, order_id, item_sku, snapshot_name, unit_price, - quantity, line_total, vat_rate, vat_amount) + quantity, line_total, vat_rate, vat_amount, + modifiers_json, removed_ingredients_json, comment) VALUES - (:id, :oid, :sku, :name, :unit, :qty, :total, :vat_rate, :vat_amt)" + (:id, :oid, :sku, :name, :unit, :qty, :total, :vat_rate, :vat_amt, + :mods, :removed, :comment)" ); - foreach ($calc['lines_raw'] as $lr) { $stmtLine->execute([ ':id' => $generateUuidV4(), @@ -160,52 +208,74 @@ try { ':total' => $lr['line_total_grosze'], ':vat_rate' => $lr['vat_rate'], ':vat_amt' => $lr['vat_amount_grosze'], + ':mods' => $lr['modifiers_json'], + ':removed' => $lr['removed_ingredients_json'], + ':comment' => $lr['comment'], ]); } - // ÔÇö 4e. Audit trail ÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇö $stmtAudit = $pdo->prepare( - "INSERT INTO sh_order_audit (order_id, new_status, timestamp) - VALUES (:oid, 'new', :now)" + "INSERT INTO sh_order_audit (order_id, user_id, old_status, new_status, timestamp) + VALUES (:oid, :uid, NULL, 'new', :now)" ); - $stmtAudit->execute([':oid' => $orderId, ':now' => $now]); + $stmtAudit->execute([':oid' => $orderId, ':uid' => $user_id, ':now' => $now]); - // ÔÇö 4f. COMMIT ÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇöÔÇö - $pdo->commit(); + if ($source === 'ONLINE' && $lockToken !== '' && $hasCheckoutLocks) { + $stmtConsume = $pdo->prepare( + "UPDATE sh_checkout_locks + SET consumed_at = NOW(), consumed_order_id = :oid + WHERE lock_token = :tok AND tenant_id = :tid" + ); + $stmtConsume->execute([':oid' => $orderId, ':tok' => $lockToken, ':tid' => $tenant_id]); + } + $pdo->commit(); } catch (Throwable $txErr) { - $pdo->rollBack(); + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } throw $txErr; } - // ========================================================================= - // 5. SUCCESS RESPONSE - // ========================================================================= - $fmtMoney = fn(int $g): string => number_format($g / 100, 2, '.', ''); - - echo json_encode([ - 'success' => true, - 'data' => [ - 'order_id' => $orderId, - 'order_number' => $orderNumber, - 'status' => 'new', - 'grand_total' => $fmtMoney($calc['grand_total_grosze']), - 'loyalty_points_earned' => $calc['loyalty_points'], - 'cart' => $calc['response'], - ], - ]); + // Publish order.created dla ONLINE (symetria z guest_checkout w api/online/engine.php) + if ($source === 'ONLINE') { + try { + require_once __DIR__ . '/../../core/OrderEventPublisher.php'; + OrderEventPublisher::publishOrderLifecycle( + $pdo, $tenant_id, 'order.created', $orderId, + [ + 'channel' => $calc['channel'], + 'order_type' => $calc['order_type'], + 'payment_method' => $calc['payment_method'] ?? null, + 'tracking_token' => $trackingToken, + ], + ['source' => 'online', 'actorType' => 'guest', 'actorId' => $orderId] + ); + } catch (Throwable $pubErr) { + error_log('[Checkout] OrderEventPublisher failed: ' . $pubErr->getMessage()); + } + } + + $response = [ + 'order_id' => $orderId, + 'order_number' => $orderNumber, + 'status' => 'new', + 'grand_total' => $fmtMoney((int)$calc['grand_total_grosze']), + 'loyalty_points_earned' => $calc['loyalty_points'], + 'cart' => $calc['response'], + ]; + if ($trackingToken !== null) { + $response['tracking_token'] = $trackingToken; + $response['source'] = 'ONLINE'; + } + $respond(true, $response, 'OK'); } catch (CartEngineException $e) { - http_response_code(400); - echo json_encode(['success' => false, 'message' => $e->getMessage()]); + $respond(false, null, $e->getMessage(), 400); } catch (PDOException $e) { - http_response_code(500); - echo json_encode(['success' => false, 'message' => 'Database error. Please try again later.']); error_log('[Checkout] PDOException: ' . $e->getMessage()); + $respond(false, null, 'Database error. Please try again later.', 500); } catch (Throwable $e) { - http_response_code(500); - echo json_encode(['success' => false, 'message' => 'Internal server error.']); error_log('[Checkout] ' . $e->getMessage()); + $respond(false, null, 'Internal server error.', 500); } - -exit; diff --git a/core/auth_guard.php b/core/auth_guard.php index e7feefd..890405c 100644 --- a/core/auth_guard.php +++ b/core/auth_guard.php @@ -21,19 +21,59 @@ if (session_status() === PHP_SESSION_NONE) { session_start(); } -// ============================================================================= -// !! DEVELOPMENT MOCK ÔÇö REMOVE BEFORE PRODUCTION !! -// Provides a fallback session so endpoints work before the login UI is wired. -// ============================================================================= -$_SESSION['tenant_id'] = $_SESSION['tenant_id'] = 1; -$_SESSION['user_id'] = $_SESSION['user_id'] = 2; -// ============================================================================= +// JWT takes precedence over session ÔÇö critical for multi-tab scenarios where +// Dispatcher (manager session) and Driver App (driver JWT) share the same browser. +$authHeader = $_SERVER['HTTP_AUTHORIZATION'] + ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] + ?? ''; + +if ($authHeader === '' && function_exists('apache_request_headers')) { + $apacheHeaders = apache_request_headers(); + $authHeader = $apacheHeaders['Authorization'] ?? $apacheHeaders['authorization'] ?? ''; +} + +$_authFromJwt = false; + +if ($authHeader !== '') { + $token = $authHeader; + if (str_starts_with($token, 'Bearer ')) { + $token = substr($token, 7); + } + $token = trim($token); + + if ($token !== '') { + try { + require_once __DIR__ . '/JwtProvider.php'; + $payload = JwtProvider::decode($token, JWT_SECRET); + + $tid = (int)($payload['tenant_id'] ?? 0); + $uid = (int)($payload['user_id'] ?? 0); + + if ($tid <= 0 || $uid <= 0) { + throw new \Exception('Invalid token payload'); + } + + $_SESSION['tenant_id'] = $tid; + $_SESSION['user_id'] = $uid; + $_authFromJwt = true; + } catch (\Throwable $e) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(401); + die(json_encode([ + 'success' => false, + 'message' => 'Invalid or expired token.', + 'data' => null, + ])); + } + } +} -if (empty($_SESSION['user_id']) || empty($_SESSION['tenant_id'])) { +if (!$_authFromJwt && (empty($_SESSION['user_id']) || empty($_SESSION['tenant_id']))) { header('Content-Type: application/json; charset=utf-8'); + http_response_code(401); die(json_encode([ 'success' => false, - 'message' => 'Unauthorized access. Session expired or invalid.', + 'message' => 'Unauthorized access. No token provided.', 'data' => null, ])); } diff --git a/core/db_config.php b/core/db_config.php index b71f32f..4e262e2 100644 --- a/core/db_config.php +++ b/core/db_config.php @@ -1,5 +1,17 @@ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (PDOException $e) { - die(json_encode(["status" => "error", "message" => "B┼é─ůd krytyczny po┼é─ůczenia z baz─ů: " . $e->getMessage()])); + http_response_code(500); + error_log('[DB Config] Connection error: ' . $e->getMessage()); + die(json_encode(['success' => false, 'message' => 'Database connection error.', 'data' => null])); } -?> \ No newline at end of file diff --git a/core/js/api_client.js b/core/js/api_client.js index b2c8512..12f5433 100644 --- a/core/js/api_client.js +++ b/core/js/api_client.js @@ -20,9 +20,16 @@ */ async function request(endpoint, options = {}) { try { + const headers = { 'Content-Type': 'application/json' }; + + const token = localStorage.getItem('sh_token'); + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } + const fetchOptions = { method: options.method || 'GET', - headers: { 'Content-Type': 'application/json' }, + headers, }; if (options.body !== undefined) { @@ -30,9 +37,17 @@ } const response = await fetch(endpoint, fetchOptions); - const json = await response.json(); - // Normalizacja ÔÇö zapewnij, ┼╝e zwracany obiekt zawsze ma klucze protoko┼éu V4 + if (response.status === 401) { + console.warn('[ApiClient] 401 ÔÇö token expired or invalid. Redirecting to login.'); + localStorage.removeItem('sh_token'); + const loginPath = window.location.pathname.replace(/modules\/.*$/, '') + 'login.html'; + window.location.href = loginPath; + return { success: false, message: 'Sesja wygas┼éa. Przekierowanie do logowania...', data: null }; + } + + const json = await response.json(); + return { success: json.success === true, message: json.message ?? '', diff --git a/core/js/core_validator.js b/core/js/core_validator.js index 49c5148..b182ddd 100644 --- a/core/js/core_validator.js +++ b/core/js/core_validator.js @@ -1,84 +1,147 @@ /** - * ­čŤí´ŞĆ SLICEHUB CORE VALIDATOR v1.0 (Ultimate) - * Cel: Jedno ┼║r├│d┼éo prawdy dla konwersji jednostek i czysto┼Ťci danych (ASCII). + * SliceHub ÔÇö SliceValidator (globalny walidator / konwerter jednostek). + * Za┼éaduj przed warehouse_*.js / studio_*.js. */ +(function () { + 'use strict'; -const SliceValidator = { - // 1. Twardy s┼éownik jednostek bazowych - units: { - 'kg': { type: 'weight', ratio: 1, label: 'Kilogram' }, - 'g': { type: 'weight', ratio: 0.001, label: 'Gram' }, - 'l': { type: 'volume', ratio: 1, label: 'Litr' }, - 'ml': { type: 'volume', ratio: 0.001, label: 'Mililitr' }, - 'szt': { type: 'count', ratio: 1, label: 'Sztuka' }, - 'por': { type: 'count', ratio: 1, label: 'Porcja' }, - 'opak':{ type: 'count', ratio: 1, label: 'Opakowanie' } // Gotowe pod KSeF - }, - - // 2. Stra┼╝nik ASCII - Zamienia "M─ůka pszenna" na "MAKA_PSZENNA" do cel├│w technicznych - sanitizeKey: function(str) { - if (typeof str !== 'string') return ''; - return str.normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") // Usuwanie polskich znak├│w (diakrytyk├│w) - .replace(/\s+/g, '_') // Spacje na podkre┼Ťlenia - .replace(/[^a-zA-Z0-9_]/g, '') // Usuwanie znak├│w specjalnych - .toUpperCase(); - }, - - // 3. Inteligentny Konwerter Jednostek - convert: function(value, fromUnit, toUnit) { - const val = parseFloat(value.toString().replace(',', '.')); - if (isNaN(val) || val < 0) return { error: 'Nieprawid┼éowa warto┼Ť─ç liczbowa.' }; - - const unitFrom = this.units[fromUnit.toLowerCase()]; - const unitTo = this.units[toUnit.toLowerCase()]; - - // Flaga do Szybkiej Naprawy (Inline Fix) je┼Ťli jednostki nie ma w s┼éowniku - if (!unitFrom || !unitTo) { - return { - needsFix: true, - msg: `Brak przelicznika dla: ${!unitFrom ? fromUnit : toUnit}`, - originalValue: val - }; + const UNITS_CANON = { + kg: { base: 'kg', factor: 1 }, + g: { base: 'kg', factor: 0.001 }, + dag: { base: 'kg', factor: 0.01 }, + l: { base: 'l', factor: 1 }, + ml: { base: 'l', factor: 0.001 }, + szt: { base: 'szt', factor: 1 }, + pcs: { base: 'szt', factor: 1 }, + op: { base: 'op', factor: 1 }, + }; + + function sanitizeKey(value) { + return String(value || '') + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, '_') + .replace(/[^A-Za-z0-9_/-]/g, '') + .toUpperCase(); + } + + function standardizeUnit(qty, fromUnit) { + const from = String(fromUnit || '').toLowerCase().trim(); + const map = UNITS_CANON[from]; + if (!map) return { value: qty, unit: from }; + return { value: qty * map.factor, unit: map.base }; + } + + function convertUnit(qty, fromUnit, toUnit) { + const from = String(fromUnit || '').toLowerCase().trim(); + const to = String(toUnit || '').toLowerCase().trim(); + const fMap = UNITS_CANON[from]; + const tMap = UNITS_CANON[to]; + if (!fMap || !tMap || fMap.base !== tMap.base) return null; + return (qty * fMap.factor) / tMap.factor; + } + + function validatePrice(val) { + const n = parseFloat(val); + return Number.isFinite(n) && n >= 0 ? n : null; + } + + function generateSku(name) { + return sanitizeKey(name).replace(/-/g, '_').slice(0, 32); + } + + function escapeHtml(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + /** + * Safe numeric parser ÔÇö handles comma decimals, empty strings, NaN. + * Returns a finite number or null. + */ + function safeParse(raw) { + if (raw == null || raw === '') return null; + const n = parseFloat(String(raw).replace(',', '.')); + return Number.isFinite(n) ? n : null; + } + + /** + * convert(qty, fromUnit, toUnit) + * + * High-level unit converter used by recipe / food-cost UI. + * Parses user input defensively (commaÔćĺdot, NaN guard), converts via + * UNITS_CANON, and returns a structured result safe for downstream math. + * + * @returns {{ success: boolean, value: number, msg?: string }} + */ + function convert(qty, fromUnit, toUnit) { + const parsed = safeParse(qty); + if (parsed === null) { + return { success: false, value: 0, msg: `Nieprawid┼éowa warto┼Ť─ç liczbowa: "${qty}"` }; } - // Blokada mieszania typ├│w (np. waga na sztuki) bez dodatkowego przelicznika - if (unitFrom.type !== unitTo.type) { - return { - error: 'Konflikt typ├│w', - msg: `Nie mo┼╝na bezpo┼Ťrednio przeliczy─ç ${unitFrom.type} na ${unitTo.type}. Wymagany przelicznik dedykowany.` - }; + const from = String(fromUnit || '').toLowerCase().trim(); + const to = String(toUnit || '').toLowerCase().trim(); + + if (from === to) { + return { success: true, value: parsed }; } - // Operacja matematyczna sprowadzaj─ůca do bazy - const baseValue = val * unitFrom.ratio; - const finalValue = baseValue / unitTo.ratio; - - return { - success: true, - value: finalValue, - baseValue: baseValue, - formatted: `${finalValue.toFixed(3)} ${toUnit}` - }; - }, - - // 4. Modu┼é Receptur (Stra┼╝nik do studio_recipe.js) - validateRecipeRow: function(qty, userUnit, stockUnit) { - const result = this.convert(qty, userUnit, stockUnit); - - if (result.needsFix) { - console.warn("­čÜĘ [Core Validator] Wykryto nieznan─ů jednostk─Ö. Gotowo┼Ť─ç do wywo┼éania Inline Fix."); - return { status: 'fix_required', data: result }; + const fMap = UNITS_CANON[from]; + const tMap = UNITS_CANON[to]; + + if (!fMap || !tMap) { + const unknown = !fMap ? from : to; + return { success: false, value: 0, msg: `Nieznana jednostka: "${unknown}"` }; } - if (result.error) { - console.error("­čÜĘ [Core Validator] B┼é─ůd krytyczny: ", result.msg); - return { status: 'error', msg: result.msg }; + if (fMap.base !== tMap.base) { + return { success: false, value: 0, msg: `Niezgodne grupy jednostek: "${from}" (${fMap.base}) Ôćĺ "${to}" (${tMap.base})` }; + } + + const result = (parsed * fMap.factor) / tMap.factor; + return { success: true, value: result }; + } + + /** + * validateRecipeRow(qty, usageUnit, baseUnit) + * + * Pre-flight check before adding / updating a recipe ingredient row. + * Verifies that usageUnit can be converted to baseUnit. + * + * @returns {{ status: 'ok'|'error', msg?: string }} + */ + function validateRecipeRow(qty, usageUnit, baseUnit) { + const parsed = safeParse(qty); + if (parsed === null || parsed < 0) { + return { status: 'error', msg: `Nieprawid┼éowa ilo┼Ť─ç: "${qty}"` }; + } + + const from = String(usageUnit || '').toLowerCase().trim(); + const to = String(baseUnit || '').toLowerCase().trim(); + + if (from === to) return { status: 'ok' }; + + const fMap = UNITS_CANON[from]; + const tMap = UNITS_CANON[to]; + + if (!fMap || !tMap || fMap.base !== tMap.base) { + return { status: 'error', msg: `Niezgodno┼Ť─ç jednostek: "${from}" Ôćĺ "${to}"` }; } - return { status: 'ok', value: result.value }; + return { status: 'ok' }; } -}; -// Zabezpieczenie przed nadpisaniem logiki przez inne skrypty -Object.freeze(SliceValidator); \ No newline at end of file + window.SliceValidator = Object.freeze({ + UNITS_CANON, + sanitizeKey, + standardizeUnit, + convertUnit, + convert, + validateRecipeRow, + safeParse, + validatePrice, + generateSku, + escapeHtml, + }); +})(); diff --git a/modules/studio/index.html b/modules/studio/index.html index b70196d..2bf3265 100644 --- a/modules/studio/index.html +++ b/modules/studio/index.html @@ -2,7 +2,7 @@ - + SliceHub - Studio Menu Ultimate @@ -10,7 +10,8 @@