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
-
-
-
-
-
-
-
-
-
-
-
-
Oczekuj─ůce (Nowe) 1
-
-
-
-
-
- 1x Pizza Parma (32cm)
- - BEZ RUKOLI
- 1x Sos Czosnkowy
-
-
-
-
-
-
-
W przygotowaniu 2
-
-
-
-
-
- 2x Burger Drwala
- 1x Frytki Du┼╝e
-
-
-
-
-
-
-
-
- 3x Pizza Margherita
-
-
-
-
-
-
-
Gotowe / Wydane 1
-
-
-
-
-
- 1x Zestaw Sushi (M)
-
-
-
-
-
-
-
-
-
Konfiguracja Odbioru
-
Plik docelowy: orders_settings.php
-
-
-
-
-
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
- 0
-
-
-
-
-
-
-
-
-
-
Flota
-
-
-
-
-
-
-
-
-
-
-
-
Wybierz Typ Zam├│wienia
-
-
-
-
-
-
-
Wybierz numer stolika:
-
-
-
-
-
-
-
-
-
-
-
-
-
- Tryb Edycji
-
-
-
-
-
-
-
-
-
Koszyk
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Suma
- 0.00 zł
-
-
-
-
-
-
-
-
-
-
-
ROZLICZ / PARAGON
-
Zam├│wienie
-
-
Potwierd┼║ p┼éatno┼Ť─ç:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
ANULUJ ZAMÓWIENIE
-
Dla bonu
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Sk┼éadniki (Kliknij by usun─ů─ç)
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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 => ``).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 @@